# Log Every Agent Invocation: Building Usage Analytics with Claude Code's Stop Hook and transcript_path

> Source: <https://dev.to/bokuwalily/log-every-agent-invocation-building-usage-analytics-with-claude-codes-stop-hook-and-6oc>
> Published: 2026-07-23 05:00:06+00:00

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