{"slug": "log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook", "title": "Log Every Agent Invocation: Building Usage Analytics with Claude Code's Stop Hook and transcript_path", "summary": "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.", "body_md": "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**.\n\nRight now, `~/.claude/logs/agent-invocations.jsonl`\n\nholds **553** recorded invocations. Over the last 7 days, `general-purpose`\n\nwas called 58 times and `Explore`\n\n24 times. And the fact that **47 agents were never called even once** — that was invisible without this mechanism.\n\nAs agent definitions pile up under `~/.claude/agents/`\n\n, 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.\n\nClaude Code has a mechanism that fires a Stop hook when a session ends, and its payload includes `transcript_path`\n\n. 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.\n\nSession ends → Stop hook fires → `stop_hooks_combined.sh`\n\nreceives the payload → passes it to `stop_agent_tracker.sh`\n\n→ Python reads the transcript and appends to the JSONL.\n\nHere is how the actual `~/.local/bin/stop_hooks_combined.sh`\n\nis wired up.\n\n```\n# stdin → tmpfile に保存して複数 hook へ順次渡す\ncat > \"$PAYLOAD\"\n\nfor hook in \\\n  \"$HOME/.claude/hooks/stop_notify.sh\" \\\n  \"$HOME/.claude/hooks/stop_cost_log.sh\" \\\n  \"$HOME/.claude/hooks/stop_agent_tracker.sh\" \\\n  \"$HOME/.claude/hooks/stop_session_summary.sh\" \\\n  \"$HOME/.discord/stop_post_session.sh\"\ndo\n  [ -x \"$hook\" ] && \"$hook\" < \"$PAYLOAD\" || true\ndone\n```\n\nThe 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\"`\n\nredirect.\n\n`stop_agent_tracker.sh`\n\nThe script has two layers: a bash wrapper and inline Python.\n\n``` bash\n#!/usr/bin/env bash\nset -uo pipefail\n\nLOG_DIR=\"$HOME/.claude/logs\"\nOUT_LOG=\"$LOG_DIR/agent-invocations.jsonl\"\n\nINPUT=$(cat)\nexport STOP_INPUT=\"$INPUT\"\nexport OUT_LOG_PATH=\"$OUT_LOG\"\n\npython3 - <<'PY'\n# ...\nPY\n```\n\nThe bash side just reads stdin and sets environment variables. All the actual work is delegated to Python.\n\n``` php\nuses = {}    # tool_use_id -> (ts, name, input, caller)\nresults = {} # tool_use_id -> (ts, is_error)\n\nwith open(tp, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n    for line in f:\n        rec = json.loads(line)\n        content = rec.get(\"message\", {}).get(\"content\")\n        if not isinstance(content, list):\n            continue\n        for b in content:\n            btype = b.get(\"type\")\n            if btype == \"tool_use\" and b.get(\"name\") == \"Agent\":\n                inp = b.get(\"input\") or {}\n                if \"subagent_type\" not in inp:\n                    continue\n                uid = b.get(\"id\")\n                uses[uid] = (rec.get(\"timestamp\"), b.get(\"name\"), inp, b.get(\"caller\"))\n            elif btype == \"tool_result\":\n                rid = b.get(\"tool_use_id\")\n                if rid:\n                    results[rid] = (rec.get(\"timestamp\"), bool(b.get(\"is_error\")))\n```\n\nThe key point is filtering on `name == \"Agent\"`\n\n. In Claude Code transcripts, the Task tool is also recorded as `name: \"Agent\"`\n\n. Only entries with `subagent_type`\n\nin `input`\n\nare what we want — this distinguishes them from plain `claude`\n\ncalls.\n\nA Stop hook can fire multiple times within the same session (`/clear`\n\n, long sessions). To record without duplicates, the script sweeps the existing log before writing.\n\n```\nseen_ids = set()\nif os.path.exists(out_path):\n    with open(out_path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n        for line in f:\n            r = json.loads(line)\n            # 同一セッション内の同一 tool_use_id だけをスキップ\n            if r.get(\"session_id\") == sid and r.get(\"tool_use_id\"):\n                seen_ids.add(r[\"tool_use_id\"])\n```\n\nThe combination of `session_id`\n\nand `tool_use_id`\n\nis the uniqueness key. If you deduplicate on `tool_use_id`\n\nalone, records get silently dropped when IDs happen to collide across different sessions.\n\nThe difference between the timestamp of the `tool_use`\n\nrecord and the timestamp of its matching `tool_result`\n\nis the agent's execution time.\n\n``` python\ndef parse_ts(s):\n    if not s:\n        return None\n    return datetime.datetime.fromisoformat(s.replace(\"Z\", \"+00:00\"))\n\nt0 = parse_ts(use_ts)\nt1 = parse_ts(res_ts)\nif t0 and t1:\n    duration_ms = int((t1 - t0).total_seconds() * 1000)\n```\n\nIf the `tool_result`\n\nhasn't arrived yet (e.g. the session was interrupted), the record is written with `status: \"pending\"`\n\nand `duration_ms: null`\n\n.\n\n```\n{\n  \"ts\": \"2026-05-28T16:27:41.766Z\",\n  \"session_id\": \"sess_xxx\",\n  \"cwd\": \"~\",\n  \"tool_use_id\": \"toolu_014MMSdJubC215oLCxfcrjok\",\n  \"subagent_type\": \"general-purpose\",\n  \"description\": \"launchd + cron 総監査\",\n  \"duration_ms\": 177,\n  \"status\": \"ok\",\n  \"caller\": {\"type\": \"direct\"}\n}\n```\n\nThe `description`\n\nfield is clipped at 300 characters. In the transcript it's free-form text, so it occasionally gets enormous.\n\n`agent-usage-summary.sh`\n\nThe accumulated JSONL is aggregated by `~/.claude/scripts/agent-usage-summary.sh`\n\n.\n\n```\nagent-usage-summary.sh           # デフォルト 7d\nagent-usage-summary.sh 30d       # 30日\nagent-usage-summary.sh 7d 30d    # 両ウィンドウ同時\n```\n\nRunning it produces this (actual numbers from today).\n\n```\n=== Agent usage (last 7d) ===\ntotal invocations: 86  unique types: 4\n\nTop 10:\n  agent                                     calls  errors\n  general-purpose                              58       0\n  Explore                                      24       0\n  fork                                          2       0\n  reviewer                                      2       0\n\n0-call agents (defined locally but not used in 7d): 47\n  - INDEX\n  - a11y-architect\n  - architect\n  - build-error-resolver\n  - code-architect\n  ...\n```\n\n** general-purpose at 58 calls, Explore at 24.** These two types account for 95% of all invocations over 7 days. And\n\n`~/.claude/scripts/dashboard.sh`\n\nfolds this output into `dashboard.md`\n\nevery day for always-on visibility.\n\n```\necho \"## 🤖 Agent 呼び出し (7d)\"\nAGENT_OUT=$(~/.claude/scripts/agent-usage-summary.sh 7d 2>/dev/null)\nTOP_BLOCK=$(echo \"$AGENT_OUT\" | awk '\n  /^Top 10:/ { in_block=1; next }\n  /^$/ && in_block { exit }\n  in_block { print }\n' | head -5)\necho \"$TOP_BLOCK\"\n```\n\nNote:Agents defined as`*.md`\n\nfiles under`~/.claude/agents/`\n\nare treated as \"known agents,\" and any that never appear in the log are listed as \"0-call.\" Non-agent files like`INDEX.md`\n\nslip in too, so in practice`INDEX`\n\nshows up at the top of the 0-call list every time. If that bothers you, filter it out with something like`.startswith(\"INDEX\")`\n\nbefore building`known_agents`\n\n.\n\n`dashboard.sh`\n\n`dashboard.sh`\n\nwrites the agent summary out to `~/.claude/dashboard.md`\n\nalongside 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.\n\n`stop_hooks_combined.sh`\n\nwas designed to write a tmpfile first and then redirect it into each hook. My first attempt had each hook `cat`\n\nstdin directly, and every hook after the first got nothing.`mktemp`\n\nfails when TMPDIR is broken`stop_hooks_combined.sh`\n\n: `|| PAYLOAD=\"/tmp/stop-hook.$$.$RANDOM.json\"`\n\n. If you redirect to an empty path, every hook silently becomes a no-op.`session_id × tool_use_id`\n\npair.`name == \"Task\"`\n\nreturns nothing`name: \"Agent\"`\n\n. This isn't documented anywhere; I found it by grepping the actual files.`json.loads`\n\ngot slow in later aggregation.`tool_result`\n\narrives`status: \"pending\"`\n\nand `duration_ms: null`\n\nlets aggregation queries exclude it with an `is not null`\n\nfilter.`transcript_path`\n\n, which means `name == \"Agent\"`\n\nand an `input.subagent_type`\n\nare agent invocations`session_id × tool_use_id`\n\n`duration_ms`\n\nis the timestamp delta between `tool_use`\n\nand `tool_result`\n\n`agent-usage-summary.sh`\n\nis 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).\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook", "canonical_source": "https://dev.to/bokuwalily/log-every-agent-invocation-building-usage-analytics-with-claude-codes-stop-hook-and-6oc", "published_at": "2026-07-23 05:00:06+00:00", "updated_at": "2026-07-23 05:29:25.592189+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents"], "entities": ["Claude Code", "Python"], "alternates": {"html": "https://wpnews.pro/news/log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook", "markdown": "https://wpnews.pro/news/log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook.md", "text": "https://wpnews.pro/news/log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook.txt", "jsonld": "https://wpnews.pro/news/log-every-agent-invocation-building-usage-analytics-with-claude-code-s-stop-hook.jsonld"}}