DEV Community

Lily
Lily

Posted on • Originally published at dev.to

Parse transcript.jsonl Directly to See What's Actually Being Called

Claude Code's /usage tells you how much you're spending, but not what you're spending it on. In my previous post, "Logging Agent invocations to a file and piping them to Slack," I wrote about recording tool calls. This time is a follow-up: a script that parses the already-recorded transcript.jsonl directly and tallies how many times each Skill, each Agent, and each MCP server was called.

Claude Code's /usage shows cost ($) and remaining quota, but it can't show you "which Skill did I use most this week" or "which Agent is the heaviest." Every tool_use block is preserved in transcript.jsonl, so by reading it directly you can surface usage at a layer /usage never tells you about.

The problem: /usage only shows cost

What /usage reports is the consumption rate for 5-hour and 7-day blocks, plus per-session token counts. It does not give you a per-item breakdown like "this AutoTrigger Skill was actually called 20 times this week" or "the general-purpose Agent accounts for half of all Agent calls."

To do cost optimization or check the health of your environment, you need the line items for "what are the tokens being spent on." transcript.jsonl is the ideal source for this, and every session is accumulated under ~/.claude/projects/.

The structure of transcript.jsonl

Each file is JSONL, one record per line. Each turn Claude Code sent or received becomes one record.

{
  "type": "assistant",
  "uuid": "...",
  "timestamp": "...",
  "sessionId": "...",
  "message": {
    "model": "claude-sonnet-4-6",
    "role": "assistant",
    "content": [
      {
        "type": "tool_use",
        "id": "toolu_01M5Rw...",
        "name": "Read",
        "input": { "file_path": "~/..." },
        "caller": { "type": "direct" }
      }
    ],
    "usage": { "input_tokens": 12043, "output_tokens": 421 }
  }
}
Enter fullscreen mode Exit fullscreen mode

All you need for the tally is the type == "tool_use" blocks inside message.content[]. The name field is the tool name, and input holds the arguments.

Tool Tally key Extracted from
Skill input.skill Skill name (also namespace-split for the plugin:name form)
Agent input.subagent_type Subagent type
mcp__* 2nd segment of name MCP server name (mcp__<server>__<tool>)

The heart of the script

~/.claude/scripts/usage-breakdown.sh embeds Python via a Bash heredoc. Bash receives the directory and the window period, and Python does a full scan of the files.

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

python3 - "$TR_DIR" "$ARG" <<'PY'
import sys, json, datetime, glob, collections, os
tr_dir, arg = sys.argv[1], sys.argv[2]
SHORT = arg == "--short"
Enter fullscreen mode Exit fullscreen mode

The window period is filtered by the file's mtime. When the --short flag comes in, the parsing is all the same; only the output is narrowed down to a single line.

The parsing itself is simple:

for path in glob.glob(f"{tr_dir}/*.jsonl"):
    mtime = os.path.getmtime(path)
    if mtime < cutoff_ts: continue
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        for line in f:
            rec = json.loads(line)
            msg = rec.get("message", {}) if isinstance(rec.get("message"), dict) else {}
            content = msg.get("content") if isinstance(msg, dict) else None
            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
                if name == "Skill":
                    skill_name = inp.get("skill", "?")
                    skill_calls[skill_name] += 1
                elif name == "Agent":
                    agent_calls[inp.get("subagent_type", "?")] += 1
                elif name.startswith("mcp__"):
                    parts = name.split("__")
                    if len(parts) >= 2:
                        mcp_calls[parts[1]] += 1
Enter fullscreen mode Exit fullscreen mode

For the plugin:name form of Skill (e.g. hookify:configure), I also split on : and tally the namespace separately. This lets me see which plugin package is heavy on a different axis from the individual skill names.

Running it for real

Running it for the last 7 days looks like this.

$ usage-breakdown.sh 7d
=== usage breakdown (last 7d, 51 transcripts) ===

total tool_use: 4230

--- top tools ---
   2583  Bash
    567  Read
    402  Edit
    151  Write
    103  mcp__claude-in-chrome__computer
     73  mcp__claude-in-chrome__javascript_tool
     62  TaskUpdate
     50  ToolSearch
     42  mcp__claude-in-chrome__navigate
     36  TaskCreate

--- top skills (2 unique) ---
      1  harness-audit
      1  superpowers:brainstorming

--- top agents (5 unique) ---
     20  general-purpose
      7  Explore
      4  ?
      2  reviewer
      1  fork

--- top MCP servers (4 unique) ---
    261  claude-in-chrome
      3  claude_ai_Google_Calendar
      3  computer-use
      2  claude_ai_Gmail

--- top plugin namespaces (1 unique) ---
      1  superpowers
Enter fullscreen mode Exit fullscreen mode

This week had 51 sessions and 4,230 tool_use calls, with Bash accounting for 61% of the total (2,583 calls). For MCP, claude-in-chrome was in a league of its own at 261 calls. For Agents, general-purpose was the most frequent at 20, with Explore at 7.

The reason there are only 2 Skill calls total is that this week I had a lot of setups where Skills fire via AutoTrigger (keyword matching in CLAUDE.md) rather than being invoked manually. AutoTrigger should show up in the transcript as Skill tool_use, so the low count is honestly reflected.

:::message
There are 4 Agents where subagent_type comes out as "?". These are calls that didn't specify subagent_type on the Agent tool (effectively the default general-purpose), which the script falls back to via inp.get("subagent_type", "?"). In practice, when "?" starts increasing, narrowing down "which Session" is useful for discovering missing subagent specifications.
:::

The --short mode for embedding in the status line

--short outputs a one-line summary.

$ usage-breakdown.sh --short
4235 tool_use across 51 sessions (7d)
Enter fullscreen mode Exit fullscreen mode

I've built it into a custom script for Claude Code's status line. Placing it alongside the cost quota (the output of the previous post's token-budget-advisor.sh) puts you in a state where "usage and remaining quota are visible at a glance."

# an example statusline hook
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short)
USAGE=$(~/.claude/scripts/usage-breakdown.sh --short)
echo "๐Ÿ’ฐ $BUDGET | ๐Ÿ”ง $USAGE"
Enter fullscreen mode Exit fullscreen mode

Pitfalls I hit

  • The mtime filter is file-granular, so it's coarse โ€” when a session spans multiple days, older turns get included in the tally as part of a "recent file." To be strict you'd need line-level filtering via rec.get("timestamp"), but for grasping trends, mtime was good enough.
  • It doesn't pick up JSONL in subdirectories โ€” glob.glob(f"{tr_dir}/*.jsonl") only looks one flat level. Subagent transcripts go under <session-uuid>/subagents/agent-*.jsonl, so they're outside the tally in the current implementation. Switching to **/*.jsonl would pick them up, but it's heavy, so I've left it out intentionally.
  • Long MCP tool names like mcp__claude-in-chrome__computer โ€” since the implementation takes the 2nd segment of split("__"), only parts[1] remains and the server name is extracted correctly. However, in cases where the server name itself contains __, it will be misextracted (haven't run into it so far).
  • The 30d window takes several minutes โ€” because it full-scans over 890 files, using 7d or --short for daily use and only reaching for 30d when digging deep is the realistic approach.

Summary

  • If you pick up just the message.content[].type == "tool_use" from transcript.jsonl, you can get the call counts for Skills, Agents, and MCP servers
  • Just outputting most_common(10) from a Python counter immediately shows you the "heavy items"
  • The mtime period filter and the embedded Python heredoc fit it all into a single Bash script
  • Making it a one-line summary with --short and always displaying it in the status line lets you monitor the health of your environment on an axis separate from cost

Look at cost with /usage, and look at the per-item breakdown with this script. Combining the two raises the resolution of "how many tokens were spent for what."


Written by **Lily* โ€” I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio ยท X ยท GitHub*

Top comments (0)