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

작성자

카테고리:

← 피드로
DEV Community · Lily · 2026-07-23 개발(SW)

Lily

Lily

Posted on Jul 23 • Originally published at dev.to

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*

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다