{"slug": "which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl", "title": "Which Skill Is Quietly Burning Your Tokens? Find Out From transcript.jsonl", "summary": "A developer created usage-breakdown.sh, a 106-line shell script that parses Claude Code's transcript.jsonl files to tally tool call counts per Skill, Agent, and MCP server, addressing the lack of granular cost breakdown in Claude Code's /usage command. The script uses Python's Counter to count tool_use events, filters by file modification time for time windows, and can output a one-line summary for status bars.", "body_md": "Your monthly Claude Code bill went up 20%. You know that much. What you don't know is *which* Skill did it — and nothing in the tooling will tell you.\n\nRun `/usage`\n\nin Claude Code and you get `claude-sonnet-4-6: ¥3,240`\n\n— **a per-model total and nothing else**. \"More expensive than last week\" is visible. \"Which Skill caused it\" is not. `usage-breakdown.sh`\n\ncloses that gap. It's a 106-line shell script that parses transcript.jsonl with Python and tallies call counts per Skill, Agent, and MCP server using `Counter`\n\n.\n\nThis article walks through how the script works and how to run it, with the actual code and actual numbers.\n\nClaude Code streams every operation during a session into `.jsonl`\n\nfiles under `~/.claude/projects/`\n\n. It's JSONL — one event per line, one file per session. The files sit under a `<project-id>/`\n\ndirectory.\n\nThe skeleton of a single record looks like this:\n\n```\n{\n  \"message\": {\n    \"role\": \"assistant\",\n    \"content\": [\n      {\n        \"type\": \"tool_use\",\n        \"name\": \"Skill\",\n        \"input\": {\n          \"skill\": \"pre-completion-self-audit\"\n        }\n      }\n    ]\n  }\n}\n```\n\nInside `message.content[]`\n\nsit `\"type\": \"tool_use\"`\n\nblocks. The `name`\n\nfield is the name of the tool that was invoked. **The Bash tool, the Edit tool, the Skill tool, the Agent tool, MCP calls — all of it is recorded in this same format.**\n\nOnce I noticed that, the thought was: run this through a Counter and everything becomes visible. For the Skill tool, the skill name lives in `input.skill`\n\n; for the Agent tool it's `input.subagent_type`\n\n; and for MCP servers, the tool-name convention `mcp__<server>__<tool>`\n\nlets you extract the server name by splitting on `__`\n\n. The structure is consistent, so the parser comes out surprisingly simple.\n\nWhat Claude Code's `/usage`\n\ncommand outputs is a per-model cost total for a period.\n\n```\nModel                    Cost\nclaude-sonnet-4-6        ¥3,240\nclaude-opus-4-8          ¥  892\n```\n\nUseful as far as it goes, but **the breakdown of that cost is invisible**. You can't see which session, which Skill, how many times it was called, or where the tokens went.\n\n`usage-breakdown.sh`\n\ndoesn't tally token volume — it tallies **call counts**. Accurate token totals would require picking up the `usage`\n\nobject from API responses (per a comment in the script: *token counts need usage-object aggregation, but call count is a stand-in for now*), yet call counts alone are enough to outline what's heavy. A Skill called 100 times and a Skill called once differ by orders of magnitude in token consumption.\n\nTallying every session mixes in old logs and blurs comparisons. The script cuts a time window using each file's `mtime`\n\n.\n\n```\ncutoff_ts = (now - datetime.timedelta(days=days)).timestamp()\n\nfor path in glob.glob(f\"{tr_dir}/*.jsonl\"):\n    mtime = os.path.getmtime(path)\n    if mtime < cutoff_ts: continue\n```\n\nThe default is `7d`\n\n; an argument changes it to `30d`\n\nor `14d`\n\n. Passing `--short`\n\nemits only a one-line summary suited to a statusline.\n\n```\n5015 tool_use across 39 sessions (7d)\n```\n\nPipe that into a macOS status bar widget and the total call count accumulating week over week stays permanently visible.\n\nThe script maintains four counters.\n\n```\nskill_calls    = collections.Counter()   # Skillツール → input.skill\nagent_calls    = collections.Counter()   # Agentツール → input.subagent_type\nmcp_calls      = collections.Counter()   # mcp__<server>__* → サーバー名\nplugin_skill_calls = collections.Counter()  # plugin:skill 形式のnamespace\n```\n\n`tool_calls`\n\nis the counter for all tools; the four above are its breakdown. **Among Skills, those in plugin:skill-name form get bundled per namespace** — and that granularity earns its keep in practice. There are moments when counting\n\n`superpowers:brainstorming`\n\nand `superpowers:research`\n\nseparately tells you nothing you want; you only want to know that the `superpowers`\n\nplugin is heavy.The decision logic is a plain branch.\n\n```\nif name == \"Skill\":\n    skill_name = inp.get(\"skill\", \"?\")\n    if \":\" in skill_name:\n        plugin_skill_calls[skill_name.split(\":\", 1)[0]] += 1\n    skill_calls[skill_name] += 1\nelif name == \"Agent\":\n    st = inp.get(\"subagent_type\", \"?\")\n    agent_calls[st] += 1\nelif name.startswith(\"mcp__\"):\n    parts = name.split(\"__\")\n    if len(parts) >= 2:\n        mcp_calls[parts[1]] += 1\n```\n\nThe loop just reads one file line by line and calls `json.loads`\n\n. Parse errors are swallowed by `try/except`\n\n. The whole aggregation core is under 30 lines.\n\nHere's the script's processing flow as an ASCII diagram.\n\n```\n~/.claude/projects/\n  └─ -Users-<username>/\n       ├─ abc123.jsonl  ─┐\n       ├─ def456.jsonl   ├─► mtime >= cutoff? ─NO─► スキップ\n       └─ ghi789.jsonl  ─┘        │\n                                  YES\n                                   │\n                            jsonl 1行ずつ読む\n                                   │\n                            message.content[]\n                                   │\n                     type==\"tool_use\" のブロック抽出\n                                   │\n                    ┌──────────────┼──────────────┐\n                    │              │              │\n                 name==           name==       name starts\n                \"Skill\"          \"Agent\"      \"mcp__\"\n                    │              │              │\n              input.skill    subagent_type   __split[1]\n                    │              │              │\n               skill_calls    agent_calls    mcp_calls\n                    │              │              │\n                    └──────────────┴──────────────┘\n                                   │\n                         Counter.most_common(10)\n                                   │\n                            stdout へ出力\n```\n\n`usage-breakdown.sh`\n\nsplits into three parts.\n\n**Part 1: The shell layer (lines 1–16)**\n\nHandles argument parsing, checking that the transcript directory exists, and handing off to the Python script.\n\n``` bash\n#!/usr/bin/env bash\nset -uo pipefail\nARG=\"${1:-7d}\"\nTR_DIR=\"$HOME/.claude/projects/-Users-<username>\"\n[ -d \"$TR_DIR\" ] || { echo \"(no transcript dir)\"; exit 0; }\n\npython3 - \"$TR_DIR\" \"$ARG\" <<'PY'\n```\n\nThe `<<'PY' ... PY`\n\nheredoc embeds the Python code inline. The point of that structure is to keep everything in one file without dropping an external `.py`\n\nalongside it. Operationally that means: nothing to install, no path resolution, works no matter where you call it from.\n\n**Part 2: Argument parsing and time-window computation (lines 18–28)**\n\n```\nSHORT = arg == \"--short\"\ndays = int((arg if arg.endswith(\"d\") else \"7d\").rstrip(\"d\"))\ncutoff_ts = (now - datetime.timedelta(days=days)).timestamp()\n```\n\nAfter branching on the `--short`\n\nflag, `7d`\n\nis converted to the number `7`\n\n. The `endswith(\"d\")`\n\ncheck accepts both the `30d`\n\nform and a bare integer.\n\n**Part 3: File scanning and the aggregation core (lines 37–73)**\n\n`glob.glob`\n\ngets the list of JSONL files, and only those passing the mtime filter are opened. The pipeline is: `json.loads`\n\nper line → walk the `message.content`\n\nlist → extract `tool_use`\n\nblocks → increment the four Counters.\n\n```\nfor path in glob.glob(f\"{tr_dir}/*.jsonl\"):\n    mtime = os.path.getmtime(path)\n    if mtime < cutoff_ts: continue\n    total_files += 1\n    with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n        for line in f:\n            rec = json.loads(line)\n            msg = rec.get(\"message\", {})\n            content = msg.get(\"content\")\n            if not isinstance(content, list): continue\n            for block in content:\n                if block.get(\"type\") != \"tool_use\": continue\n                name = block.get(\"name\", \"\")\n                inp = block.get(\"input\") or {}\n                tool_calls[name] += 1\n                # ... 4本の分岐\n```\n\n`errors=\"replace\"`\n\nis passed to keep an occasional invalid byte from halting the read of an entire file.\n\n**Part 4: Output (lines 75–106)**\n\nWith `--short`\n\n, a one-line summary; in normal mode, the top 10 per section via `most_common(10)`\n\n.\n\n```\nprint(f\"=== usage breakdown (last {days}d, {total_files} transcripts) ===\")\nprint(f\"\\ntotal tool_use: {sum(tool_calls.values())}\")\n\nif skill_calls:\n    print(f\"\\n--- top skills ({len(skill_calls)} unique) ---\")\n    for sk, n in skill_calls.most_common(10):\n        print(f\"  {n:>5}  {sk}\")\n```\n\nThe right-aligned `{n:>5}`\n\nformat keeps the columns lined up even when digit counts differ. A small touch for readability in the terminal.\n\n```\n=== usage breakdown (last 7d, 39 transcripts) ===\n\ntotal tool_use: 5015\n\n--- top tools ---\n   3656  Bash\n    508  Edit\n    304  Read\n    240  Write\n     37  Monitor\n     35  ToolSearch\n     23  AskUserQuestion\n     20  TaskUpdate\n     19  mcp__plugin_playwright_playwright__browser_take_screenshot\n     16  mcp__claude-in-chrome__navigate\n\n--- top skills (3 unique) ---\n      3  artifact-design\n      1  dataviz\n      1  claude-api\n\n--- top agents (1 unique) ---\n      1  code-reviewer\n\n--- top MCP servers (4 unique) ---\n     79  plugin_playwright_playwright\n     45  claude-in-chrome\n     15  claude_ai_Google_Calendar\n      2  claude_ai_Gmail\n```\n\n39 sessions over 7 days, 5,015 total tool calls. **Bash leads by a mile at 3,656 calls (72.9%)**, with Edit behind it at 508. Skills and Agents are lower than I expected — what that number means is dug into in the next section. Widen to 30 days and the picture changes.\n\n```\n=== usage breakdown (last 30d, 203 transcripts) ===\n\ntotal tool_use: 21215\n\n--- top agents (7 unique) ---\n     94  general-purpose\n     22  Explore\n      6  reviewer\n      ...\n\n--- top MCP servers (5 unique) ---\n   1571  claude-in-chrome\n     81  plugin_playwright_playwright\n     54  computer-use\n```\n\nOver a 30-day span, `claude-in-chrome`\n\nhits 1,571 calls — **about 366 per week**. Among Agents, `general-purpose`\n\nhits 94 (23 per week). Steady-state weight that was hard to see in a 7-day window surfaces in a 30-day one.\n\nThat gap — **weight invisible in a short window and only visible in a long one** — is where the tuning points for scheduled automation live.\n\nReading the aggregation core (lines 37–73), you'll notice `try/except`\n\nis two layers deep.\n\n```\nfor path in glob.glob(f\"{tr_dir}/*.jsonl\"):\n    try:\n        mtime = os.path.getmtime(path)\n        if mtime < cutoff_ts: continue\n        total_files += 1\n        with open(path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n            for line in f:\n                try:\n                    rec = json.loads(line)\n                except: continue  # ← 内側\n                ...\n    except Exception:\n        continue  # ← 外側\n```\n\n**The inner try/except** wraps only\n\n`json.loads`\n\n. Since JSONL is one record per line, a single line failing to parse doesn't stop the rest from being read. It just `continue`\n\ns to the next line.**The outer try/except Exception** catches per-file exceptions. Permission error, file deleted, mtime lookup failed — whichever happens,\n\n`continue`\n\nskips that file and moves to the next. That's why the `total_files`\n\nincrement sits inside the outer `try`\n\n: you only want to count a file you successfully opened.The reason for two layers is the difference in granularity. \"This file can't be read\" and \"this line isn't JSON\" are different failures with different continuation scopes. Collapse them into one layer with a per-file `continue`\n\nand a single file with a broken first line costs you the remaining few thousand lines wholesale.\n\nLine 47 has a guard that looks belt-and-suspenders at first glance.\n\n```\nmsg = rec.get(\"message\", {}) if isinstance(rec.get(\"message\"), dict) else {}\n```\n\n`rec.get(\"message\", {})`\n\nlooks like it'd be enough, but it isn't. transcript.jsonl contains records with `\"message\": null`\n\n. `null`\n\nis valid JSON, so it sails through `json.loads`\n\n, but in Python it becomes `None`\n\n. `{}.get(\"content\")`\n\nis fine; `None.get(\"content\")`\n\ndies with `AttributeError`\n\n. Without the pattern of confirming it's a `dict`\n\nvia `isinstance`\n\nbefore calling `.get()`\n\n, every `null`\n\nrecord you hit gets caught by the inner `except`\n\ninstead.\n\nFor the same reason, line 54 has its own defense.\n\n```\ninp = block.get(\"input\") or {}\n```\n\n`block.get(\"input\")`\n\ncan return `None`\n\n. `None or {}`\n\nevaluates to `{}`\n\n, so the subsequent `inp.get(\"skill\", \"?\")`\n\nruns safely. It's shorter than writing `if inp is None: inp = {}`\n\n, and it conveys the intent — \"for both None and an empty dict, I want an empty dict\" — in a single line.\n\nAnd line 50.\n\n```\nfor block in content:\n    if not isinstance(block, dict): continue\n```\n\n`content`\n\nhas been confirmed to be a `list`\n\n, but that doesn't guarantee its elements are all `dict`\n\n. Browsing Claude Code transcripts, you occasionally find records where `content`\n\nis a list of strings (in some cases where text blocks and tool blocks are mixed). Checking `isinstance(block, dict)`\n\nper element and skipping non-dicts is the robust move.\n\nLook carefully at line 16.\n\n```\npython3 - \"$TR_DIR\" \"$ARG\" <<'PY'\n```\n\n**The single quotes on <<'PY' are absolutely required.** Make it\n\n`<<PY`\n\n(unquoted) and shell variable expansion runs inside the heredoc. If the Python code contains even one occurrence of something like `$tr_dir`\n\n, the shell will try to expand it and it mutates into an unintended string. `f\"{tr_dir}/*.jsonl\"`\n\nis a Python f-string so there's no `$`\n\n, but anything that looks like `$1`\n\nor `${HOME}`\n\nbreaks. Quoting the delimiter as in `<<'PY'`\n\nfully disables expansion inside the heredoc, and the Python code is passed to python3's stdin as the literal string it is.The advantage of embedding Python inline via a heredoc is that **everything lives in one file**. Drop the script in some directory, put it on your PATH, and that's all it takes to run. If you're calling `~/.claude/scripts/usage-breakdown.sh`\n\nfrom launchd, there's no separate Python file path to manage. External file dependencies break silently the moment that file is deleted or moved.\n\nThe block at lines 59–61 is small, but its value shows once you actually use it.\n\n```\nif \":\" in skill_name:\n    plugin_skill_calls[skill_name.split(\":\", 1)[0]] += 1\nskill_calls[skill_name] += 1\n```\n\nThe `1`\n\nin `split(\":\", 1)`\n\nmatters. Capping the max split count at 1 means `expo:eas-hosting`\n\nbecomes `[\"expo\", \"eas-hosting\"]`\n\n, and even if a skill name shaped like `expo:eas:hosting`\n\nexisted, it becomes `[\"expo\", \"eas:hosting\"]`\n\n— the namespace portion alone is extracted correctly.\n\nIncrementing both `plugin_skill_calls`\n\nand `skill_calls`\n\nis about separating the axes of aggregation. `skill_calls`\n\ntallies individual skill names; `plugin_skill_calls`\n\ntallies namespaces. In a weekly report you can pull both the bundled number (\"used the expo plugin 12 times total\") and the breakdown (\"expo:eas-hosting 5 times, expo:expo-upgrade 4 times\").\n\nThe single line `--short`\n\nmode returns is meant to be called directly from a macOS status bar widget (xbar, Übersicht, etc.) and displayed.\n\n```\n5015 tool_use across 39 sessions (7d)\n```\n\nThe setup: a launchd plist runs the script every 5 minutes, writes the result to `/tmp/usage-short.txt`\n\n, and the widget reads that. Since the widget only reads a file, periodic runs during a Claude Code session don't conflict with anything. Without the `--short`\n\nflag the output runs over 10 lines — too long to embed in a widget. Designing in a per-purpose output-format switch from the start saves you from getting stuck later.\n\n`errors=\"replace\"`\n\n, Whole Files Never Made It Through\nThe first version didn't have `errors=\"replace\"`\n\n.\n\n```\nwith open(path, \"r\", encoding=\"utf-8\") as f:  # ← errorsなし\n```\n\nRun it that way and some transcript files throw `UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position ...`\n\nand the script stops. Because the file was wrapped in the outer `try/except Exception`\n\nit didn't actually halt — but that entire file got skipped by `continue`\n\n.\n\nThe cause is transcripts that contain base64 PNG image data. In sessions where you use screenshots in Claude Code, images are written into transcript.jsonl as base64. The base64 string itself is ASCII so it reads fine as UTF-8, but occasionally malformed JSONL gets generated with binary mixed in. Passing `errors=\"replace\"`\n\nsubstitutes unreadable bytes with `U+FFFD`\n\n(REPLACEMENT CHARACTER) and keeps reading. Even if a broken byte inside a JSON value becomes a replacement character, `json.loads`\n\nparses the whole line — so as long as the structure is intact, the parse goes through. If the structure is broken, the inner `except: continue`\n\ncatches it.\n\n`errors=\"replace\"`\n\ntrades tolerance for data loss in exchange for getting through the whole file. For usage aggregation, \"did I manage to scan every file\" matters more than byte-level precision, so the call was right.\n\nAfter using the script for a while, there was a day where I noticed that a supposedly last-7-days tally \"obviously has data from old sessions mixed in.\" The total count in the output had ballooned to 3–4× the usual, and looking at the contents, exchanges from two weeks earlier were included.\n\nThe cause was **a backup software restore**. Sync your home directory with Time Machine or rsync and the files under `~/.claude/projects/`\n\nget overwritten by copies. The copy changes each file's **creation time** — and mtime becomes \"the time it was copied\" too. The contents are an old session's transcript, but the mtime is today's date.\n\n```\ncutoff_ts = (now - datetime.timedelta(days=days)).timestamp()\nfor path in glob.glob(f\"{tr_dir}/*.jsonl\"):\n    mtime = os.path.getmtime(path)\n    if mtime < cutoff_ts: continue\n```\n\nThe mtime filter looks at \"when this file was last modified,\" so every file whose mtime got refreshed by the copy is treated as recent. 164 files became false positives in one go, and for 5 consecutive windows the state persisted: \"zero new sessions, yet the numbers keep inflating.\"\n\nThe fundamental fix is to parse the `日時:`\n\nfield inside the transcript and judge by actual session time. But that raises implementation cost, so the current workaround is to **aggregate over a 30-day window and read the long-term trend**. Even when a bulk copy injects false positives, they settle into statistical outliers within a 30-day total. If you use a 7-day window, the only option is an operational rule you hold yourself: don't trust the numbers for the few days right after a backup.\n\nHad I not caught this and instead trusted a number like \"Skills were called 100 times last week\" while changing a plist's StartInterval, I might have been adjusting something that needed no adjustment at all. The lesson: before taking a tool's output at face value, get in the habit of questioning once — \"what does the method of obtaining this number depend on?\"\n\n`json.loads`\n\nException Hid the Side Effects\nThe inner `except: continue`\n\nisn't `except Exception: continue`\n\n— it's a **bare except:**. That catches every exception including\n\n`BaseException`\n\n. It swallows `KeyboardInterrupt`\n\nand `SystemExit`\n\nalike.At first I thought that was fine, but during debugging there was a time when hitting `Ctrl+C`\n\nto stop the script didn't stop it. `KeyboardInterrupt`\n\nwas being caught by the inner `except:`\n\nand `continue`\n\nd. Once the loop advanced into the next file, it never reached the outer `try`\n\nor `except`\n\neither.\n\nThe fix is narrowing the inner one to `except (json.JSONDecodeError, ValueError): continue`\n\n. The reasons `json.loads`\n\nfails are effectively just `JSONDecodeError`\n\n(Python 3.5+) or, rarely, `ValueError`\n\n. Anything else (including `KeyboardInterrupt`\n\n) shouldn't be caught on the inside — it should propagate to the outer `except Exception`\n\n, or reach the user. The current code still has the bare `except:`\n\n, and I do think there's room for improvement there even now. It has never caused a problem in actual operation, but \"`Ctrl+C`\n\nmight not work\" is behavior worth knowing about.\n\n`most_common`\n\nReturn Value Was Doing the Work Twice\nAt the output stage, there was a point where I tried to further re-order the return value of `skill_calls.most_common(10)`\n\nwith `sorted()`\n\n. I wanted it in alphabetical order too.\n\n```\n# やってしまったパターン\nfor sk, n in sorted(skill_calls.most_common(10), key=lambda x: x[0]):\n    print(f\"  {n:>5}  {sk}\")\n```\n\nThis takes `most_common(10)`\n\nfirst and then reorders by name, so the result is \"the overall top 10, alphabetized.\" Seems fine at a glance, but it creates confusion: \"the 11th-most-frequent Skill should sort near the top by name, and it isn't showing.\"\n\nThe job of `most_common()`\n\nis to return the counter in descending frequency. The argument `10`\n\nnarrows it to the top 10 by frequency. If you're going to sort afterwards, you should either pass no argument to `most_common()`\n\nand take everything before sorting, or use a different data structure suited to the purpose from the start.\n\nThis one was fixed with a one-line change, but the real problem was using it without understanding how Collections' Counter works. `Counter`\n\nis internally a subclass of `dict`\n\n, and `most_common()`\n\nis a heap-based `O(n log k)`\n\noperation. Even with a million entries, the top 10 comes back fast. Conversely, fetching everything and sorting it yourself is `O(n log n)`\n\n. At small scale it's noise, but the difference shows up once transcripts grow.\n\nThis is the part that extracts the server name from the `mcp__<server>__<tool>`\n\nform.\n\n```\nelif name.startswith(\"mcp__\"):\n    parts = name.split(\"__\")\n    if len(parts) >= 2:\n        mcp_calls[parts[1]] += 1\n```\n\nIn an early version without the `if len(parts) >= 2:`\n\nguard, when a tool name of just `mcp__`\n\ngot mixed in (`parts`\n\nbeing `[\"mcp\", \"\"]`\n\n), `parts[1]`\n\nbecame an empty string and `mcp_calls[\"\"] += 1`\n\npiled up. An empty entry reading `\" 23 \"`\n\nappeared in the output and at first I had no idea what it was.\n\nThe cause of empty tool names is incomplete records. Occasionally an MCP response gets interrupted and a transcript is generated with the tool name cut off mid-way. The `len(parts) >= 2`\n\nguard is the simplest fix, and once I added it the empty entries disappeared. Going further, I'd also want to skip cases where `parts[1]`\n\nis an empty string, so it really should be `if len(parts) >= 2 and parts[1]:`\n\n. In the current code, an empty `parts[1]`\n\nisn't rejected and becomes `mcp_calls[\"\"]`\n\n, but it never reaches counts high enough to land in `most_common(10)`\n\n, so there's no practical harm.\n\nBeyond the 5 items detailed in the previous section (UnicodeDecodeError, mtime false positives, the bare `except:`\n\n, the `most_common`\n\ndouble work, and empty MCP server names), here are the finer traps I hit in real operation.\n\n**TR_DIR is hardcoded, so it doesn't run in anyone else's environment.** Line 13 of the script has a username baked in, like `TR_DIR=\"$HOME/.claude/projects/-Users-yourname\"`\n\n. I tried to carry it to another account and another machine and it didn't work. `-Users-$(whoami)`\n\nsolves it, but unless you know the naming convention where slashes in the directory name are replaced with hyphens, you can't even identify the cause.\n\n**The --short flag and the day count are mutually exclusive.** Arguments take only the single\n\n`$1`\n\n, so writing `usage-breakdown.sh --short 30d`\n\nignores `30d`\n\n. The combination \"I want a one-line summary of 30 days\" can't be expressed directly. In practice you either take just the first line of `usage-breakdown.sh 30d`\n\n's output, or modify the script to handle `$1`\n\n/ `$2`\n\n.**Called from launchd, python3 isn't on the PATH.** A script launched by launchd runs with a PATH of only\n\n`/usr/bin:/bin:/usr/sbin:/sbin`\n\n. Since the python3 installed by homebrew or nvm lives in `/usr/local/bin`\n\nor `/opt/homebrew/bin`\n\nand the like, a plain launchd plist gives you `python3: command not found`\n\n. You need to spell out `<key>PATH</key>`\n\nunder the plist's `<key>EnvironmentVariables</key>`\n\n, or specify an absolute path (`/opt/homebrew/bin/python3`\n\n) at the top of the script instead of `/usr/bin/env python3`\n\n.**I changed StartInterval and forgot to reload the plist.** Even after fixing `StartInterval`\n\nin `~/Library/LaunchAgents/com.lily.usage-breakdown.plist`\n\nfrom `300`\n\n(5 minutes) to `1800`\n\n(30 minutes), forgetting `launchctl unload`\n\n+ `launchctl load`\n\nleaves it running on the old setting. The reliable way to check whether the change took is `launchctl list com.lily.usage-breakdown`\n\nand looking at `LastExitStatus`\n\nand the next fire time. I've had the state where I thought I'd changed it by editing the file and in fact nothing had changed — and didn't notice for days.\n\n** glob.glob's return order isn't guaranteed.** The ordering varies by filesystem. The totals come out the same, and a changed processing order doesn't affect the\n\n`total_files`\n\ncount (the counters are cumulative), but when debugging and trying to trace which position a particular file gets processed in, the order changing every time is confusing. If you want the order pinned down for sure, spelling out `sorted(glob.glob(...))`\n\nis safer.**The most_common(10) cap is fixed, so as Skills grow the tail goes invisible.** Once the environment has more than 50 Skills installed, anything below 10th place drops out of view. For weekly tuning purposes, narrowing by a threshold like \"everything over 100 calls\" is more realistic. The current code hardcodes the output count, so as the environment grows, the information you want gets truncated.\n\n**I changed a plist based only on the 7-day-window numbers.** In a week with few transcripts (e.g. right after a long holiday), absolute numbers look low. \"`claude-in-chrome`\n\nwas only called 20 times\" reads, from the vantage of a normal 140-per-week, as \"this week just happened to be light.\" Without always pairing it with the 30-day window, you'll judge on an outlier and do unnecessary tuning.\n\n**I forgot the guard for records where content is a string instead of a list.** The\n\n`isinstance(content, list)`\n\ncheck is there now, but the first version made do with just `msg.get(\"content\")`\n\n, so when a string came in, `for block in content:`\n\nbecame an iteration over characters. Since each character gets `isinstance(block, dict)`\n\n-tested and dropped, there was no practical harm — but the loop count ballooned pointlessly and it got noticeably slow on transcripts with large file sizes.**I wasn't saving the script's output, so I couldn't compare over time.** Just running `usage-breakdown.sh 7d`\n\nby hand and eyeballing it leaves \"up or down versus last week\" to memory. Once I changed it to write to `/tmp/usage-weekly-$(date +%Y-%m-%d).txt`\n\nonce a week via launchd, a comparison like \"MCP was 550 calls per week last month and halved to 280 this month\" became objectively available.\n\n**I misread the intent of the split for skill names containing : going only into plugin_skill_calls.** Incrementing both\n\n`plugin_skill_calls`\n\nand `skill_calls`\n\nis about separating the aggregation axes, but at first I thought it was a bug and deleted the increment to `skill_calls`\n\n. The result was that every individual skill-name tally became `?`\n\n, producing output that read \"Skills are being called but all the names are unknown\" — very confusing. When reading code, it's important to check why an `if`\n\nis used rather than an `elif`\n\nin a decision branch.Without `<<'PY'`\n\n, shell expansion runs whenever the Python code contains a `$`\n\n(f-strings, or anything that looks like `$HOME`\n\n). If `<<PY`\n\nis working for you, that's luck — it breaks the moment you add a variable named `$tr_dir`\n\n. Fix this as a rule for handling inline Python scripts.\n\n`errors=\"replace\"`\n\nto `open()`\n\nLogs and transcripts and the like can have binary mixed in (base64 screenshots, copies of external content, etc.). `errors=\"replace\"`\n\nsuits aggregation work that prioritizes \"did I manage to scan every file\" over data precision. It's a move for raising completion rate.\n\n\"Per-file failure\" and \"per-line failure\" have different continuation scopes. Design in this two-layer structure from the start and, when debugging, you can trace \"which line is broken\" and \"which file is broken\" separately.\n\n`rec.get(\"message\", {})`\n\ncan't reject `\"message\": null`\n\n. The single line `isinstance(rec.get(\"message\"), dict)`\n\ncompletely seals off the path where None raises an AttributeError. transcript.jsonl routinely contains values outside the spec, so it's safer to distrust types and check every time.\n\n`or {}`\n\nPattern Handles None and Empty dict at Once\n\n```\ninp = block.get(\"input\") or {}\n```\n\nShorter than `if inp is None: inp = {}`\n\n, and clearer in intent. The `or`\n\noperator replaces every falsy value (None, empty dict, empty string) with `{}`\n\n, so the subsequent `.get()`\n\nis safe to call.\n\nHolding both `skill_calls`\n\n(individual names) and `plugin_skill_calls`\n\n(namespaces) lets you extract the higher-level view (\"the expo plugin as a whole is heavy\") and the individual view (\"expo:eas-hosting 5 times\") from the same run. Sorting out \"what unit do I want to look at this in\" at design time is easier than adding an axis later.\n\nThe 7-day window is sensitive to noise. Mix in a holiday, a backup restore, or a heavy-work week and it becomes an outlier. Line up the 30-day window, decide whether it's \"consistently high or high only this week,\" and only then touch the plist — this two-window practice prevents wobble in tuning decisions.\n\nSeparating the detailed mode humans read from the one-line mode you feed to widgets and log files from the beginning lets you reuse the same script across multiple contexts. Trying to add an output format later multiplies the branches in the code and hurts clarity.\n\n```\n~/.claude/scripts/usage-breakdown.sh 7d > /tmp/usage-$(date +%Y-%m-%d).txt\n```\n\nJust running this every Monday via launchd lets you see the comparison against 4 weeks ago with `diff`\n\n. When you want to verify a feeling like \"costs seem to have gone up lately\" with numbers, having logs on hand versus not changes the conversation entirely.\n\n```\nlaunchctl unload ~/Library/LaunchAgents/com.lily.usage-breakdown.plist\nlaunchctl load  ~/Library/LaunchAgents/com.lily.usage-breakdown.plist\n```\n\nRewriting the file alone doesn't apply it. Build the habit of checking `\"NextScheduledFire\"`\n\nin `launchctl list com.lily.usage-breakdown`\n\nto confirm the next fire time follows the new `StartInterval`\n\n.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>\n</dict>\n```\n\nWhen running a script that uses homebrew or nvm tools from launchd, without this it fails silently with `command not found`\n\n. There are environments where `/usr/bin/env python3`\n\nalone isn't enough.\n\n`parts[1]`\n\nToo\n\n```\nif len(parts) >= 2 and parts[1]:\n    mcp_calls[parts[1]] += 1\n```\n\n`len(parts) >= 2`\n\nalone lets the empty string from `mcp__`\n\n→ `[\"mcp\", \"\"]`\n\nslip through. Adding `and parts[1]`\n\nprevents an empty key from contaminating `most_common`\n\n.\n\nCarry a dependency on an external Python file and it breaks silently when the file is moved or deleted. The inline heredoc approach with `<<'PY' ... PY`\n\nis the easiest way to make a single script self-contained so it runs as-is wherever you put it.\n\nThe mtime window story is the archetype. A number appears, but unless you understand its basis — which field of which file is being read — you'll drive tuning on a false premise. The right order is: trace the script's behavior by hand once, grasp the limitation that \"false positives appear after a backup,\" and then put it into steady operation.\n\nWhat Claude Code's `/usage`\n\ngives you is only \"the per-model total.\" The 106-line `usage-breakdown.sh`\n\nis the script I wrote to close that gap — it parses transcript.jsonl with Python and tallies call counts per Skill, Agent, and MCP server with a Counter.\n\nRun it over 7 days and the reality shows up as `Bash: 3,656 calls (72.9%)`\n\n; widen it to 30 days and steady-state weight like `claude-in-chrome: 1,571 calls (366/week equivalent)`\n\nsurfaces. Using those numbers to identify components exceeding 100 weekly calls and adjust a plist's StartInterval — that was the goal of this whole procedure.\n\nLined up like that the gotchas look like a lot, but every one is a pitfall I could only have noticed after reading the actual code. Read through the aggregation core at lines 30–50 by hand once and trace the behavior, and that alone prevents half of them. The rest are environment dependencies specific to the launchd combination, and they clear up once you've nailed PATH and unload/load.\n\nWhat holds up a self-driving environment isn't just how smart the individual Skills and MCP servers are — it's having **a mechanism that shows you, in numbers, which component is running how much** in constant operation. You can't improve what you can't measure. The same data is already piling up in your own transcript.jsonl, so you can run this today.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl", "canonical_source": "https://dev.to/bokuwalily/which-skill-is-quietly-burning-your-tokens-find-out-from-transcriptjsonl-n65", "published_at": "2026-08-26 00:42:35+00:00", "updated_at": "2026-08-26 01:13:21.669517+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Claude Code", "Anthropic", "Python"], "alternates": {"html": "https://wpnews.pro/news/which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl", "markdown": "https://wpnews.pro/news/which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl.md", "text": "https://wpnews.pro/news/which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl.txt", "jsonld": "https://wpnews.pro/news/which-skill-is-quietly-burning-your-tokens-find-out-from-transcript-jsonl.jsonld"}}