cd /news/developer-tools/log-every-agent-invocation-building-… · home topics developer-tools article
[ARTICLE · art-69676] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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.

read6 min views1 publishedJul 23, 2026

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.

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.

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.

#!/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.

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

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 likeINDEX.md

slip in too, so in practiceINDEX

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 buildingknown_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 brokenstop_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 nothingname: "Agent"

. This isn't documented anywhere; I found it by grepping the actual files.json.loads

got slow in later aggregation.tool_result

arrivesstatus: "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 invocationssession_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 (already published).

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/log-every-agent-invo…] indexed:0 read:6min 2026-07-23 ·