cd /news/developer-tools/which-skill-is-quietly-burning-your-โ€ฆ ยท home โ€บ topics โ€บ developer-tools โ€บ article
[ARTICLE ยท art-111087] src=dev.to โ†— pub= topic=developer-tools verified=true sentiment=ยท neutral

Which Skill Is Quietly Burning Your Tokens? Find Out From transcript.jsonl

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.

read24 min views1 publishedAug 26, 2026

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.

Run /usage

in Claude Code and you get claude-sonnet-4-6: ยฅ3,240

โ€” a per-model total and nothing else. "More expensive than last week" is visible. "Which Skill caused it" is not. usage-breakdown.sh

closes 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

.

This article walks through how the script works and how to run it, with the actual code and actual numbers.

Claude Code streams every operation during a session into .jsonl

files under ~/.claude/projects/

. It's JSONL โ€” one event per line, one file per session. The files sit under a <project-id>/

directory.

The skeleton of a single record looks like this:

{
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "tool_use",
        "name": "Skill",
        "input": {
          "skill": "pre-completion-self-audit"
        }
      }
    ]
  }
}

Inside message.content[]

sit "type": "tool_use"

blocks. The name

field 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.

Once 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

; for the Agent tool it's input.subagent_type

; and for MCP servers, the tool-name convention mcp__<server>__<tool>

lets you extract the server name by splitting on __

. The structure is consistent, so the parser comes out surprisingly simple.

What Claude Code's /usage

command outputs is a per-model cost total for a period.

Model                    Cost
claude-sonnet-4-6        ยฅ3,240
claude-opus-4-8          ยฅ  892

Useful 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.

usage-breakdown.sh

doesn't tally token volume โ€” it tallies call counts. Accurate token totals would require picking up the usage

object 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.

Tallying every session mixes in old logs and blurs comparisons. The script cuts a time window using each file's mtime

.

cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()

for path in glob.glob(f"{tr_dir}/*.jsonl"):
    mtime = os.path.getmtime(path)
    if mtime < cutoff_ts: continue

The default is 7d

; an argument changes it to 30d

or 14d

. Passing --short

emits only a one-line summary suited to a statusline.

5015 tool_use across 39 sessions (7d)

Pipe that into a macOS status bar widget and the total call count accumulating week over week stays permanently visible.

The script maintains four counters.

skill_calls    = collections.Counter()   # Skillใƒ„ใƒผใƒซ โ†’ input.skill
agent_calls    = collections.Counter()   # Agentใƒ„ใƒผใƒซ โ†’ input.subagent_type
mcp_calls      = collections.Counter()   # mcp__<server>__* โ†’ ใ‚ตใƒผใƒใƒผๅ
plugin_skill_calls = collections.Counter()  # plugin:skill ๅฝขๅผใฎnamespace

tool_calls

is 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

superpowers:brainstorming

and superpowers:research

separately tells you nothing you want; you only want to know that the superpowers

plugin is heavy.The decision logic is a plain branch.

if name == "Skill":
    skill_name = inp.get("skill", "?")
    if ":" in skill_name:
        plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
    skill_calls[skill_name] += 1
elif name == "Agent":
    st = inp.get("subagent_type", "?")
    agent_calls[st] += 1
elif name.startswith("mcp__"):
    parts = name.split("__")
    if len(parts) >= 2:
        mcp_calls[parts[1]] += 1

The loop just reads one file line by line and calls json.loads

. Parse errors are swallowed by try/except

. The whole aggregation core is under 30 lines.

Here's the script's processing flow as an ASCII diagram.

~/.claude/projects/
  โ””โ”€ -Users-<username>/
       โ”œโ”€ abc123.jsonl  โ”€โ”
       โ”œโ”€ def456.jsonl   โ”œโ”€โ–บ mtime >= cutoff? โ”€NOโ”€โ–บ ใ‚นใ‚ญใƒƒใƒ—
       โ””โ”€ ghi789.jsonl  โ”€โ”˜        โ”‚
                                  YES
                                   โ”‚
                            jsonl 1่กŒใšใค่ชญใ‚€
                                   โ”‚
                            message.content[]
                                   โ”‚
                     type=="tool_use" ใฎใƒ–ใƒญใƒƒใ‚ฏๆŠฝๅ‡บ
                                   โ”‚
                    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
                    โ”‚              โ”‚              โ”‚
                 name==           name==       name starts
                "Skill"          "Agent"      "mcp__"
                    โ”‚              โ”‚              โ”‚
              input.skill    subagent_type   __split[1]
                    โ”‚              โ”‚              โ”‚
               skill_calls    agent_calls    mcp_calls
                    โ”‚              โ”‚              โ”‚
                    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                   โ”‚
                         Counter.most_common(10)
                                   โ”‚
                            stdout ใธๅ‡บๅŠ›

usage-breakdown.sh

splits into three parts.

Part 1: The shell layer (lines 1โ€“16)

Handles argument parsing, checking that the transcript directory exists, and handing off to the Python script.

#!/usr/bin/env bash
set -uo pipefail
ARG="${1:-7d}"
TR_DIR="$HOME/.claude/projects/-Users-<username>"
[ -d "$TR_DIR" ] || { echo "(no transcript dir)"; exit 0; }

python3 - "$TR_DIR" "$ARG" <<'PY'

The <<'PY' ... PY

heredoc embeds the Python code inline. The point of that structure is to keep everything in one file without dropping an external .py

alongside it. Operationally that means: nothing to install, no path resolution, works no matter where you call it from.

Part 2: Argument parsing and time-window computation (lines 18โ€“28)

SHORT = arg == "--short"
days = int((arg if arg.endswith("d") else "7d").rstrip("d"))
cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()

After branching on the --short

flag, 7d

is converted to the number 7

. The endswith("d")

check accepts both the 30d

form and a bare integer.

Part 3: File scanning and the aggregation core (lines 37โ€“73)

glob.glob

gets the list of JSONL files, and only those passing the mtime filter are opened. The pipeline is: json.loads

per line โ†’ walk the message.content

list โ†’ extract tool_use

blocks โ†’ increment the four Counters.

for path in glob.glob(f"{tr_dir}/*.jsonl"):
    mtime = os.path.getmtime(path)
    if mtime < cutoff_ts: continue
    total_files += 1
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            rec = json.loads(line)
            msg = rec.get("message", {})
            content = msg.get("content")
            if not isinstance(content, list): continue
            for block in content:
                if block.get("type") != "tool_use": continue
                name = block.get("name", "")
                inp = block.get("input") or {}
                tool_calls[name] += 1

errors="replace"

is passed to keep an occasional invalid byte from halting the read of an entire file.

Part 4: Output (lines 75โ€“106)

With --short

, a one-line summary; in normal mode, the top 10 per section via most_common(10)

.

print(f"=== usage breakdown (last {days}d, {total_files} transcripts) ===")
print(f"\ntotal tool_use: {sum(tool_calls.values())}")

if skill_calls:
    print(f"\n--- top skills ({len(skill_calls)} unique) ---")
    for sk, n in skill_calls.most_common(10):
        print(f"  {n:>5}  {sk}")

The right-aligned {n:>5}

format keeps the columns lined up even when digit counts differ. A small touch for readability in the terminal.

=== usage breakdown (last 7d, 39 transcripts) ===

total tool_use: 5015

--- top tools ---
   3656  Bash
    508  Edit
    304  Read
    240  Write
     37  Monitor
     35  ToolSearch
     23  AskUserQuestion
     20  TaskUpdate
     19  mcp__plugin_playwright_playwright__browser_take_screenshot
     16  mcp__claude-in-chrome__navigate

--- top skills (3 unique) ---
      3  artifact-design
      1  dataviz
      1  claude-api

--- top agents (1 unique) ---
      1  code-reviewer

--- top MCP servers (4 unique) ---
     79  plugin_playwright_playwright
     45  claude-in-chrome
     15  claude_ai_Google_Calendar
      2  claude_ai_Gmail

39 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.

=== usage breakdown (last 30d, 203 transcripts) ===

total tool_use: 21215

--- top agents (7 unique) ---
     94  general-purpose
     22  Explore
      6  reviewer
      ...

--- top MCP servers (5 unique) ---
   1571  claude-in-chrome
     81  plugin_playwright_playwright
     54  computer-use

Over a 30-day span, claude-in-chrome

hits 1,571 calls โ€” about 366 per week. Among Agents, general-purpose

hits 94 (23 per week). Steady-state weight that was hard to see in a 7-day window surfaces in a 30-day one.

That gap โ€” weight invisible in a short window and only visible in a long one โ€” is where the tuning points for scheduled automation live.

Reading the aggregation core (lines 37โ€“73), you'll notice try/except

is two layers deep.

for path in glob.glob(f"{tr_dir}/*.jsonl"):
    try:
        mtime = os.path.getmtime(path)
        if mtime < cutoff_ts: continue
        total_files += 1
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                try:
                    rec = json.loads(line)
                except: continue  # โ† ๅ†…ๅด
                ...
    except Exception:
        continue  # โ† ๅค–ๅด

The inner try/except wraps only

json.loads

. Since JSONL is one record per line, a single line failing to parse doesn't stop the rest from being read. It just continue

s to the next line.The outer try/except Exception catches per-file exceptions. Permission error, file deleted, mtime lookup failed โ€” whichever happens,

continue

skips that file and moves to the next. That's why the total_files

increment sits inside the outer try

: 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

and a single file with a broken first line costs you the remaining few thousand lines wholesale.

Line 47 has a guard that looks belt-and-suspenders at first glance.

msg = rec.get("message", {}) if isinstance(rec.get("message"), dict) else {}

rec.get("message", {})

looks like it'd be enough, but it isn't. transcript.jsonl contains records with "message": null

. null

is valid JSON, so it sails through json.loads

, but in Python it becomes None

. {}.get("content")

is fine; None.get("content")

dies with AttributeError

. Without the pattern of confirming it's a dict

via isinstance

before calling .get()

, every null

record you hit gets caught by the inner except

instead.

For the same reason, line 54 has its own defense.

inp = block.get("input") or {}

block.get("input")

can return None

. None or {}

evaluates to {}

, so the subsequent inp.get("skill", "?")

runs safely. It's shorter than writing if inp is None: inp = {}

, and it conveys the intent โ€” "for both None and an empty dict, I want an empty dict" โ€” in a single line.

And line 50.

for block in content:
    if not isinstance(block, dict): continue

content

has been confirmed to be a list

, but that doesn't guarantee its elements are all dict

. Browsing Claude Code transcripts, you occasionally find records where content

is a list of strings (in some cases where text blocks and tool blocks are mixed). Checking isinstance(block, dict)

per element and skipping non-dicts is the robust move.

Look carefully at line 16.

python3 - "$TR_DIR" "$ARG" <<'PY'

The single quotes on <<'PY' are absolutely required. Make it

<<PY

(unquoted) and shell variable expansion runs inside the heredoc. If the Python code contains even one occurrence of something like $tr_dir

, the shell will try to expand it and it mutates into an unintended string. f"{tr_dir}/*.jsonl"

is a Python f-string so there's no $

, but anything that looks like $1

or ${HOME}

breaks. Quoting the delimiter as in <<'PY'

fully 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

from launchd, there's no separate Python file path to manage. External file dependencies break silently the moment that file is deleted or moved.

The block at lines 59โ€“61 is small, but its value shows once you actually use it.

if ":" in skill_name:
    plugin_skill_calls[skill_name.split(":", 1)[0]] += 1
skill_calls[skill_name] += 1

The 1

in split(":", 1)

matters. Capping the max split count at 1 means expo:eas-hosting

becomes ["expo", "eas-hosting"]

, and even if a skill name shaped like expo:eas:hosting

existed, it becomes ["expo", "eas:hosting"]

โ€” the namespace portion alone is extracted correctly.

Incrementing both plugin_skill_calls

and skill_calls

is about separating the axes of aggregation. skill_calls

tallies individual skill names; plugin_skill_calls

tallies 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").

The single line --short

mode returns is meant to be called directly from a macOS status bar widget (xbar, รœbersicht, etc.) and displayed.

5015 tool_use across 39 sessions (7d)

The setup: a launchd plist runs the script every 5 minutes, writes the result to /tmp/usage-short.txt

, 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

flag 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.

errors="replace"

, Whole Files Never Made It Through The first version didn't have errors="replace"

.

with open(path, "r", encoding="utf-8") as f:  # โ† errorsใชใ—

Run it that way and some transcript files throw UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 in position ...

and the script stops. Because the file was wrapped in the outer try/except Exception

it didn't actually halt โ€” but that entire file got skipped by continue

.

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

substitutes unreadable bytes with U+FFFD

(REPLACEMENT CHARACTER) and keeps reading. Even if a broken byte inside a JSON value becomes a replacement character, json.loads

parses the whole line โ€” so as long as the structure is intact, the parse goes through. If the structure is broken, the inner except: continue

catches it.

errors="replace"

trades 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.

After 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.

The cause was a backup software restore. Sync your home directory with Time Machine or rsync and the files under ~/.claude/projects/

get 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.

cutoff_ts = (now - datetime.timedelta(days=days)).timestamp()
for path in glob.glob(f"{tr_dir}/*.jsonl"):
    mtime = os.path.getmtime(path)
    if mtime < cutoff_ts: continue

The 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."

The fundamental fix is to parse the ๆ—ฅๆ™‚:

field 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.

Had 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?"

json.loads

Exception Hid the Side Effects The inner except: continue

isn't except Exception: continue

โ€” it's a bare except:. That catches every exception including

BaseException

. It swallows KeyboardInterrupt

and SystemExit

alike.At first I thought that was fine, but during debugging there was a time when hitting Ctrl+C

to stop the script didn't stop it. KeyboardInterrupt

was being caught by the inner except:

and continue

d. Once the loop advanced into the next file, it never reached the outer try

or except

either.

The fix is narrowing the inner one to except (json.JSONDecodeError, ValueError): continue

. The reasons json.loads

fails are effectively just JSONDecodeError

(Python 3.5+) or, rarely, ValueError

. Anything else (including KeyboardInterrupt

) shouldn't be caught on the inside โ€” it should propagate to the outer except Exception

, or reach the user. The current code still has the bare except:

, and I do think there's room for improvement there even now. It has never caused a problem in actual operation, but "Ctrl+C

might not work" is behavior worth knowing about.

most_common

Return Value Was Doing the Work Twice At the output stage, there was a point where I tried to further re-order the return value of skill_calls.most_common(10)

with sorted()

. I wanted it in alphabetical order too.

for sk, n in sorted(skill_calls.most_common(10), key=lambda x: x[0]):
    print(f"  {n:>5}  {sk}")

This takes most_common(10)

first 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."

The job of most_common()

is to return the counter in descending frequency. The argument 10

narrows it to the top 10 by frequency. If you're going to sort afterwards, you should either pass no argument to most_common()

and take everything before sorting, or use a different data structure suited to the purpose from the start.

This one was fixed with a one-line change, but the real problem was using it without understanding how Collections' Counter works. Counter

is internally a subclass of dict

, and most_common()

is a heap-based O(n log k)

operation. Even with a million entries, the top 10 comes back fast. Conversely, fetching everything and sorting it yourself is O(n log n)

. At small scale it's noise, but the difference shows up once transcripts grow.

This is the part that extracts the server name from the mcp__<server>__<tool>

form.

elif name.startswith("mcp__"):
    parts = name.split("__")
    if len(parts) >= 2:
        mcp_calls[parts[1]] += 1

In an early version without the if len(parts) >= 2:

guard, when a tool name of just mcp__

got mixed in (parts

being ["mcp", ""]

), parts[1]

became an empty string and mcp_calls[""] += 1

piled up. An empty entry reading " 23 "

appeared in the output and at first I had no idea what it was.

The 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

guard 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]

is an empty string, so it really should be if len(parts) >= 2 and parts[1]:

. In the current code, an empty parts[1]

isn't rejected and becomes mcp_calls[""]

, but it never reaches counts high enough to land in most_common(10)

, so there's no practical harm.

Beyond the 5 items detailed in the previous section (UnicodeDecodeError, mtime false positives, the bare except:

, the most_common

double work, and empty MCP server names), here are the finer traps I hit in real operation.

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"

. I tried to carry it to another account and another machine and it didn't work. -Users-$(whoami)

solves 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.

The --short flag and the day count are mutually exclusive. Arguments take only the single

$1

, so writing usage-breakdown.sh --short 30d

ignores 30d

. 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

's output, or modify the script to handle $1

/ $2

.Called from launchd, python3 isn't on the PATH. A script launched by launchd runs with a PATH of only

/usr/bin:/bin:/usr/sbin:/sbin

. Since the python3 installed by homebrew or nvm lives in /usr/local/bin

or /opt/homebrew/bin

and the like, a plain launchd plist gives you python3: command not found

. You need to spell out <key>PATH</key>

under the plist's <key>EnvironmentVariables</key>

, or specify an absolute path (/opt/homebrew/bin/python3

) at the top of the script instead of /usr/bin/env python3

.I changed StartInterval and forgot to reload the plist. Even after fixing StartInterval

in ~/Library/LaunchAgents/com.lily.usage-breakdown.plist

from 300

(5 minutes) to 1800

(30 minutes), forgetting launchctl unload

  • launchctl load

leaves it running on the old setting. The reliable way to check whether the change took is launchctl list com.lily.usage-breakdown

and looking at LastExitStatus

and 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.

** 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

total_files

count (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(...))

is 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.

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

was 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.

I forgot the guard for records where content is a string instead of a list. The

isinstance(content, list)

check is there now, but the first version made do with just msg.get("content")

, so when a string came in, for block in content:

became an iteration over characters. Since each character gets isinstance(block, dict)

-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

by 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

once a week via launchd, a comparison like "MCP was 550 calls per week last month and halved to 280 this month" became objectively available.

I misread the intent of the split for skill names containing : going only into plugin_skill_calls. Incrementing both

plugin_skill_calls

and skill_calls

is about separating the aggregation axes, but at first I thought it was a bug and deleted the increment to skill_calls

. The result was that every individual skill-name tally became ?

, 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

is used rather than an elif

in a decision branch.Without <<'PY'

, shell expansion runs whenever the Python code contains a $

(f-strings, or anything that looks like $HOME

). If <<PY

is working for you, that's luck โ€” it breaks the moment you add a variable named $tr_dir

. Fix this as a rule for handling inline Python scripts.

errors="replace"

to open()

Logs and transcripts and the like can have binary mixed in (base64 screenshots, copies of external content, etc.). errors="replace"

suits aggregation work that prioritizes "did I manage to scan every file" over data precision. It's a move for raising completion rate.

"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.

rec.get("message", {})

can't reject "message": null

. The single line isinstance(rec.get("message"), dict)

completely 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.

or {}

Pattern Handles None and Empty dict at Once

inp = block.get("input") or {}

Shorter than if inp is None: inp = {}

, and clearer in intent. The or

operator replaces every falsy value (None, empty dict, empty string) with {}

, so the subsequent .get()

is safe to call.

Holding both skill_calls

(individual names) and plugin_skill_calls

(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.

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

Separating 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.

~/.claude/scripts/usage-breakdown.sh 7d > /tmp/usage-$(date +%Y-%m-%d).txt

Just running this every Monday via launchd lets you see the comparison against 4 weeks ago with diff

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

launchctl unload ~/Library/LaunchAgents/com.lily.usage-breakdown.plist
launchctl load  ~/Library/LaunchAgents/com.lily.usage-breakdown.plist

Rewriting the file alone doesn't apply it. Build the habit of checking "NextScheduledFire"

in launchctl list com.lily.usage-breakdown

to confirm the next fire time follows the new StartInterval

.

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

When running a script that uses homebrew or nvm tools from launchd, without this it fails silently with command not found

. There are environments where /usr/bin/env python3

alone isn't enough.

parts[1]

Too

if len(parts) >= 2 and parts[1]:
    mcp_calls[parts[1]] += 1

len(parts) >= 2

alone lets the empty string from mcp__

โ†’ ["mcp", ""]

slip through. Adding and parts[1]

prevents an empty key from contaminating most_common

.

Carry 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

is the easiest way to make a single script self-contained so it runs as-is wherever you put it.

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

What Claude Code's /usage

gives you is only "the per-model total." The 106-line usage-breakdown.sh

is 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.

Run it over 7 days and the reality shows up as Bash: 3,656 calls (72.9%)

; widen it to 30 days and steady-state weight like claude-in-chrome: 1,571 calls (366/week equivalent)

surfaces. Using those numbers to identify components exceeding 100 weekly calls and adjust a plist's StartInterval โ€” that was the goal of this whole procedure.

Lined 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.

What 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.

*Written by Lily โ€” I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio ยท X ยท GitHub*

โ”€โ”€ more in #developer-tools 4 stories ยท sorted by recency
โ”€โ”€ more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain โ€” perfect for shipping the agent you just read about.

$git push zahid main
โ†’ Live at https://your-agent.zahid.host โœ“
Get free account โ†’ Pricing
from โ‚ฌ0/mo ยท no card required
LIVE [news/which-skill-is-quietโ€ฆ] indexed:0 read:24min 2026-08-26 ยท โ€”