DEV Community

Lily
Lily

Posted on • Originally published at dev.to

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

In the previous post in my "Claude Code environment" series, automatically pruning zombie agents, 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.

The problem: "Am I actually using this agent?"

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.

The overall flow

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
Enter fullscreen mode Exit fullscreen mode

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.

Implementing stop_agent_tracker.sh

The script has two layers: a bash wrapper and inline Python.

#!/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
Enter fullscreen mode Exit fullscreen mode

The bash side just reads stdin and sets environment variables. All the actual work is delegated to Python.

Pass 1: scan the entire transcript and build an index

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")))
Enter fullscreen mode Exit fullscreen mode

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.

Deduplication: skip already-recorded entries by session_id × tool_use_id

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"])
Enter fullscreen mode Exit fullscreen mode

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.

Computing duration_ms

The difference between the timestamp of the tool_use record and the timestamp of its matching tool_result is the agent's execution time.

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)
Enter fullscreen mode Exit fullscreen mode

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.

Output record format

{
  "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"}
}
Enter fullscreen mode Exit fullscreen mode

The description field is clipped at 300 characters. In the transcript it's free-form text, so it occasionally gets enormous.

Aggregating with 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    # 両ウィンドウ同時
Enter fullscreen mode Exit fullscreen mode

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
  ...
Enter fullscreen mode Exit fullscreen mode

general-purpose at 58 calls, Explore at 24. These two types account for 95% of all invocations over 7 days. And 47 agents were never called at all. This is the input data for zombie-agent pruning.

~/.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"
Enter fullscreen mode Exit fullscreen mode

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

Pitfalls I hit

  • The Stop hook's stdin can only be read oncestop_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 → added a fallback in stop_hooks_combined.sh: || PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json". If you redirect to an empty path, every hook silently becomes a no-op.
  • Deduplicating on tool_use_id alone loses records on cross-session collisions → fixed by keying on the session_id × tool_use_id pair.
  • Searching for name == "Task" returns nothing → in Claude Code transcripts, the Task tool is recorded as name: "Agent". This isn't documented anywhere; I found it by grepping the actual files.
  • description can grow to thousands of characters → without clipping at 300 characters, the JSONL bloated and json.loads got slow in later aggregation.
  • The Stop hook can fire before the tool_result arrives → e.g. when a session is force-killed. Recording it with status: "pending" and duration_ms: null lets aggregation queries exclude it with an is not null filter.

Summary

  • The Stop hook payload includes transcript_path, which means the entire conversation's tool call history is readable
  • Lines with name == "Agent" and an input.subagent_type are agent invocations
  • Deduplicate on session_id × tool_use_id to handle multiple firings within the same session
  • duration_ms is the timestamp delta between tool_use and tool_result
  • The 0-call section of 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 (already published).


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

I like that you treated agents like any other production system. Once you have usage data, decisions stop being guesses.

One metric I’d be curious to add is success rate by agent type, not just invocation count. Sometimes the most frequently used agent is also the one causing the most retries. That kind of data can completely change optimization priorities.