Log Every Agent Invocation: Building Usage Analytics with Claude Code's Stop Hook and transcript_path A developer built a usage analytics system for Claude Code agents by reading the transcript_path from the Stop hook payload and accumulating agent invocations into a JSONL log. The system has recorded 553 invocations, revealing that 47 agents were never called, which was previously invisible. The mechanism uses a bash wrapper and inline Python to parse transcripts and log each agent call with timestamp and duration. In the previous post in my "Claude Code environment" series, automatically pruning zombie agents https://zenn.dev/bokuwalily/articles/zombie-agent-pruner , I wrote about finding agents that are defined but never used. But where did those numbers come from in the first place? This post covers exactly that: a mechanism that reads the transcript path included in the Stop hook payload with Python and automatically accumulates agent invocations into a JSONL log . Right now, ~/.claude/logs/agent-invocations.jsonl holds 553 recorded invocations. Over the last 7 days, general-purpose was called 58 times and Explore 24 times. And the fact that 47 agents were never called even once — that was invisible without this mechanism. As agent definitions pile up under ~/.claude/agents/ , you lose track of "when did I last use this?" — even though you wrote them yourself. Unused definitions are actively harmful: they only add to context injection. Claude Code has a mechanism that fires a Stop hook when a session ends, and its payload includes transcript path . This is the path to the log file for the entire conversation. If you read it, you can extract everything: which agent was called, when, and how long it took. Session ends → Stop hook fires → stop hooks combined.sh receives the payload → passes it to stop agent tracker.sh → Python reads the transcript and appends to the JSONL. Here is how the actual ~/.local/bin/stop hooks combined.sh is wired up. stdin → tmpfile に保存して複数 hook へ順次渡す cat "$PAYLOAD" for hook in \ "$HOME/.claude/hooks/stop notify.sh" \ "$HOME/.claude/hooks/stop cost log.sh" \ "$HOME/.claude/hooks/stop agent tracker.sh" \ "$HOME/.claude/hooks/stop session summary.sh" \ "$HOME/.discord/stop post session.sh" do -x "$hook" && "$hook" < "$PAYLOAD" || true done The Stop hook receives its payload on stdin. To fan it out to multiple hooks, the payload goes through a tmpfile, and each hook gets it via a < "$PAYLOAD" redirect. stop agent tracker.sh The script has two layers: a bash wrapper and inline Python. bash /usr/bin/env bash set -uo pipefail LOG DIR="$HOME/.claude/logs" OUT LOG="$LOG DIR/agent-invocations.jsonl" INPUT=$ cat export STOP INPUT="$INPUT" export OUT LOG PATH="$OUT LOG" python3 - <<'PY' ... PY The bash side just reads stdin and sets environment variables. All the actual work is delegated to Python. php uses = {} tool use id - ts, name, input, caller results = {} tool use id - ts, is error with open tp, "r", encoding="utf-8", errors="replace" as f: for line in f: rec = json.loads line content = rec.get "message", {} .get "content" if not isinstance content, list : continue for b in content: btype = b.get "type" if btype == "tool use" and b.get "name" == "Agent": inp = b.get "input" or {} if "subagent type" not in inp: continue uid = b.get "id" uses uid = rec.get "timestamp" , b.get "name" , inp, b.get "caller" elif btype == "tool result": rid = b.get "tool use id" if rid: results rid = rec.get "timestamp" , bool b.get "is error" The key point is filtering on name == "Agent" . In Claude Code transcripts, the Task tool is also recorded as name: "Agent" . Only entries with subagent type in input are what we want — this distinguishes them from plain claude calls. A Stop hook can fire multiple times within the same session /clear , long sessions . To record without duplicates, the script sweeps the existing log before writing. seen ids = set if os.path.exists out path : with open out path, "r", encoding="utf-8", errors="replace" as f: for line in f: r = json.loads line 同一セッション内の同一 tool use id だけをスキップ if r.get "session id" == sid and r.get "tool use id" : seen ids.add r "tool use id" The combination of session id and tool use id is the uniqueness key. If you deduplicate on tool use id alone, records get silently dropped when IDs happen to collide across different sessions. The difference between the timestamp of the tool use record and the timestamp of its matching tool result is the agent's execution time. python def parse ts s : if not s: return None return datetime.datetime.fromisoformat s.replace "Z", "+00:00" t0 = parse ts use ts t1 = parse ts res ts if t0 and t1: duration ms = int t1 - t0 .total seconds 1000 If the tool result hasn't arrived yet e.g. the session was interrupted , the record is written with status: "pending" and duration ms: null . { "ts": "2026-05-28T16:27:41.766Z", "session id": "sess xxx", "cwd": "~", "tool use id": "toolu 014MMSdJubC215oLCxfcrjok", "subagent type": "general-purpose", "description": "launchd + cron 総監査", "duration ms": 177, "status": "ok", "caller": {"type": "direct"} } The description field is clipped at 300 characters. In the transcript it's free-form text, so it occasionally gets enormous. agent-usage-summary.sh The accumulated JSONL is aggregated by ~/.claude/scripts/agent-usage-summary.sh . agent-usage-summary.sh デフォルト 7d agent-usage-summary.sh 30d 30日 agent-usage-summary.sh 7d 30d 両ウィンドウ同時 Running it produces this actual numbers from today . === Agent usage last 7d === total invocations: 86 unique types: 4 Top 10: agent calls errors general-purpose 58 0 Explore 24 0 fork 2 0 reviewer 2 0 0-call agents defined locally but not used in 7d : 47 - INDEX - a11y-architect - architect - build-error-resolver - code-architect ... general-purpose at 58 calls, Explore at 24. These two types account for 95% of all invocations over 7 days. And ~/.claude/scripts/dashboard.sh folds this output into dashboard.md every day for always-on visibility. echo " 🤖 Agent 呼び出し 7d " AGENT OUT=$ ~/.claude/scripts/agent-usage-summary.sh 7d 2 /dev/null TOP BLOCK=$ echo "$AGENT OUT" | awk ' /^Top 10:/ { in block=1; next } /^$/ && in block { exit } in block { print } ' | head -5 echo "$TOP BLOCK" Note:Agents defined as .md files under ~/.claude/agents/ are treated as "known agents," and any that never appear in the log are listed as "0-call." Non-agent files like INDEX.md slip in too, so in practice INDEX shows up at the top of the 0-call list every time. If that bothers you, filter it out with something like .startswith "INDEX" before building known agents . dashboard.sh dashboard.sh writes the agent summary out to ~/.claude/dashboard.md alongside health status, auto-skills counts, and the launchd job list. Updated daily via cron, it lets you check every morning which agents are pulling their weight this week. stop hooks combined.sh was designed to write a tmpfile first and then redirect it into each hook. My first attempt had each hook cat stdin directly, and every hook after the first got nothing. mktemp fails when TMPDIR is broken stop hooks combined.sh : || PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json" . If you redirect to an empty path, every hook silently becomes a no-op. session id × tool use id pair. name == "Task" returns nothing name: "Agent" . This isn't documented anywhere; I found it by grepping the actual files. json.loads got slow in later aggregation. tool result arrives status: "pending" and duration ms: null lets aggregation queries exclude it with an is not null filter. transcript path , which means name == "Agent" and an input.subagent type are agent invocations session id × tool use id duration ms is the timestamp delta between tool use and tool result agent-usage-summary.sh is a detector for "agents you defined but never use"Next up: the mechanism that automatically retires those unused agents surfaced by this aggregation — the design behind automatically pruning zombie agents https://zenn.dev/bokuwalily/articles/zombie-agent-pruner already published . Written by Lily — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio https://bokuwalily.com · X https://x.com/bokuwalily · GitHub https://github.com/bokuwalily