{"slug": "7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents", "title": "7 of My 8 Claude Code Agents Had Zero Calls in 30 Days: Finding Dead Agents Automatically", "summary": "A developer found that seven of eight custom agents defined in Claude Code had zero invocations over a 30-day period, despite being injected into the system prompt on every request. The unused agent definitions consumed tokens and degraded inference quality, highlighting the need for automated monitoring of agent usage.", "body_md": "I had eight custom agents defined in Claude Code. When I finally counted, seven of them hadn't been called once in the last 30 days. What keeps my ¥1.2M/month automation setup running isn't clever prompting. It's an environment that keeps checking, automatically, whether the things I built are actually doing anything.\n\nClaude Code lets you define custom agents by dropping `.md`\n\nfiles into the `~/.claude/agents/`\n\ndirectory. You define specialists like `architect`\n\n(architecture design), `code-reviewer`\n\n(code review), and `security-reviewer`\n\n(security audits), and expect Claude Code to pick the right one on its own. It's a natural assumption.\n\nBut when you actually tally the logs, the results are surprising.\n\nTake my environment as an example. `~/.claude/agents/`\n\ncurrently holds eight agent definition files.\n\n```\narchitect.md\ncode-reviewer.md\ndatabase-reviewer.md\nINDEX.md\nplanner.md\npython-reviewer.md\nsecurity-reviewer.md\ntypescript-reviewer.md\n```\n\n`~/.claude/logs/agent-invocations.jsonl`\n\nholds 682 records spanning May 28 to August 30, 2026. Aggregating the last 30 days gives this breakdown:\n\n```\n=== Agent usage (last 30d) ===\ntotal invocations: 23  unique types: 3\n\nTop 10:\n  agent                                     calls  errors\n  Explore                                      19       0\n  general-purpose                               3       0\n  code-reviewer                                 1       0\n\n0-call agents (defined locally but not used in 30d): 7\n  - INDEX\n  - architect\n  - database-reviewer\n  - planner\n  - python-reviewer\n  - security-reviewer\n  - typescript-reviewer\n```\n\nOf the eight defined agents, exactly one, `code-reviewer`\n\n, was called even once in 30 days. The other seven had **zero calls**. 87.5% of the agents I'd defined might as well not have existed.\n\nNarrow it to the last 7 days and it gets worse: `code-reviewer`\n\ndrops out too, and the zero-call list grows to eight.\n\n```\n=== Agent usage (last 7d) ===\ntotal invocations: 3  unique types: 2\n\n0-call agents (defined locally but not used in 7d): 8\n  - INDEX\n  - architect\n  - code-reviewer\n  - database-reviewer\n  - planner\n  - python-reviewer\n  - security-reviewer\n  - typescript-reviewer\n```\n\nThis isn't just a \"what a waste\" story. **Claude Code agent definitions are injected into the system prompt on every request.** Open a large agent like `architect.md`\n\nand you'll find a definition of more than 220 lines. Seven unused agent definitions were burning tokens and quietly degrading inference quality the whole time.\n\nThe sense of accomplishment when you define an agent is real. \"From now on, my code gets reviewed automatically.\" \"When I think about architecture, an expert steps in.\" You believe that, and weeks go by.\n\nIn reality, unless an agent is explicitly specified, Claude picks the generic route (`general-purpose`\n\n) or `Explore`\n\n. Even if `code-reviewer`\n\n's description says \"MUST BE USED for all code changes,\" that's text inside the definition. Claude doesn't autonomously read that instruction and act on it. The agent only works once there's a calling prompt or calling logic on the invoking side.\n\nUnused agents cost you in two ways.\n\n**Token cost.** The length of the agent catalog injected into the system prompt is paid on every invocation. More definition files means more tokens per request, eating into a large context window.\n\n**Cognitive cost.** It's hard for a human to manually track which agents are actually functioning, and management gets more complex as definition files pile up. When definitions that don't reflect reality accumulate, trust in the environment erodes. The moment you wonder \"is this agent even running?\", your confidence in the autonomous setup wavers.\n\nThe solution is to decide based on real numbers, not gut feeling. Build an operational cycle that uses logs to automatically surface \"agents not called once in the last 30 days\" and then delete or tidy them up.\n\nThe key idea is to invest in **the environment, not the task**. Not a one-off \"delete this agent today,\" but a standing state where \"a script exists that can instantly show me unused agents at any time.\" Run it monthly, weekly, or on a cron schedule; the cadence can be decided later. What matters is being permanently in a position to judge from measured data.\n\nThe reason I could build a ¥1.2M/month autonomous environment in six months isn't that I expected \"AI to get smarter.\" It's that I invested mainly in mechanisms that constantly watch for \"AI moving in the wrong direction.\" Monitoring agent usage is one example.\n\nThe system has three layers.\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  Layer 1: 記録                                          │\n│  Claude Codeのstop hookが                               │\n│  エージェント呼び出しをJSONLへ書き出す                  │\n│                                                         │\n│  ~/.claude/logs/agent-invocations.jsonl                 │\n│  → 1行1レコード / ts・session_id・subagent_type等       │\n└────────────────────┬────────────────────────────────────┘\n                     │\n                     ▼\n┌─────────────────────────────────────────────────────────┐\n│  Layer 2: 集計                                          │\n│  agent-usage-summary.sh が指定期間のレコードを集計      │\n│                                                         │\n│  - Bash外殻（引数パース・環境変数セット）               │\n│  - Python3ヒアドキュメント（ロジック本体）              │\n│    ├ ウィンドウ期間でフィルタ                           │\n│    ├ subagent_type別にカウント                         │\n│    └ ~/.claude/agents/*.md と突き合わせ               │\n└────────────────────┬────────────────────────────────────┘\n                     │\n                     ▼\n┌─────────────────────────────────────────────────────────┐\n│  Layer 3: 出力                                          │\n│  Top10呼び出しランキング ＋ 0回エージェント一覧         │\n│                                                         │\n│  → 削除・アーカイブ・再設計の判断材料になる             │\n└─────────────────────────────────────────────────────────┘\n```\n\n(Layer 1: Recording. Claude Code's stop hook writes agent invocations to JSONL, one record per line with `ts`\n\n, `session_id`\n\n, `subagent_type`\n\n, etc. Layer 2: Aggregation. `agent-usage-summary.sh`\n\naggregates records for the given window, with a Bash shell for argument parsing and environment variables, and a Python3 heredoc for the logic: filter by window, count by `subagent_type`\n\n, cross-reference against `~/.claude/agents/*.md`\n\n. Layer 3: Output. Top 10 invocation ranking plus a zero-call agent list, which feeds delete/archive/redesign decisions.)\n\nClaude Code has a `stop hook`\n\nthat can run an arbitrary script when an agent invocation completes. I use this hook to append information about the invoked agent to a JSONL file.\n\nAn actual log record looks like this:\n\n```\n{\"ts\": \"2026-08-25T01:32:02.235Z\", \"session_id\": \"d82e3fca-d397-4f40-8268-34bdeb9de46a\", \"cwd\": \"/dev/affiliate-fc2\", \"tool_use_id\": \"toolu_01HQ6HRVEgvnNjqDrmejPZ4S\", \"subagent_type\": \"general-purpose\", \"description\": \"Find CTA redirect click data for fc2 lane\", \"duration_ms\": 3407, \"status\": \"ok\", \"caller\": {\"type\": \"direct\"}}\n{\"ts\": \"2026-08-30T08:55:08.361Z\", \"session_id\": \"36b40280-ff53-4f66-9582-aa09b7fbec80\", \"cwd\": \"/dev/note-autolike\", \"tool_use_id\": \"toolu_01RjC237NX1QwsWzVUMqHbvY\", \"subagent_type\": \"Explore\", \"description\": \"Survey note paid-article infra\", \"duration_ms\": 236, \"status\": \"ok\", \"caller\": {\"type\": \"direct\"}}\n```\n\n`ts`\n\n(timestamp), `subagent_type`\n\n(agent type), and `status`\n\n(ok/error) are the main fields used for aggregation. Since `duration_ms`\n\nis there too, you also get the elapsed time per call. My environment currently holds 682 records, three months of tracking data since the first record on May 28, 2026.\n\nThe aggregation script is 103 lines. A Bash outer shell takes the arguments, and the logic is written in a Python3 heredoc. Two reasons: parsing JSONL in pure Bash gets messy, and handling shell-integrated argument processing in pure Python is a hassle. The design plays to each one's strengths.\n\nHere's the full script.\n\n``` bash\n#!/usr/bin/env bash\n# agent-usage-summary.sh — Stop hook が記録した agent 呼び出しを集計\n#\n# 使い方:\n#   agent-usage-summary.sh           # デフォルト 7d\n#   agent-usage-summary.sh 30d       # 30日\n#   agent-usage-summary.sh 7d 30d    # 両方\n\nset -uo pipefail\n\nLOG=\"$HOME/.claude/logs/agent-invocations.jsonl\"\nAGENTS_DIR=\"$HOME/.claude/agents\"\n\nWINDOWS=(\"$@\")\nif [ ${#WINDOWS[@]} -eq 0 ]; then\n    WINDOWS=(\"7d\")\nfi\n\nif [ ! -f \"$LOG\" ]; then\n    echo \"no log yet: $LOG\"\n    exit 0\nfi\n\nexport LOG_PATH=\"$LOG\"\nexport AGENTS_DIR_PATH=\"$AGENTS_DIR\"\nexport WINDOWS_CSV=\"$(IFS=,; echo \"${WINDOWS[*]}\")\"\n\npython3 - <<'PY'\nimport os, json, datetime, glob, sys\nfrom collections import Counter\n\nlog_path = os.environ[\"LOG_PATH\"]\nagents_dir = os.environ[\"AGENTS_DIR_PATH\"]\nwindows = os.environ[\"WINDOWS_CSV\"].split(\",\")\n\ndef parse_window(s):\n    s = s.strip().lower()\n    if s.endswith(\"d\"):\n        return datetime.timedelta(days=int(s[:-1]))\n    if s.endswith(\"h\"):\n        return datetime.timedelta(hours=int(s[:-1]))\n    raise ValueError(f\"bad window: {s}\")\n\nnow = datetime.datetime.now(datetime.timezone.utc)\n\nrecords = []\nwith open(log_path, \"r\", encoding=\"utf-8\", errors=\"replace\") as f:\n    for line in f:\n        try:\n            r = json.loads(line)\n        except Exception:\n            continue\n        ts = r.get(\"ts\", \"\")\n        try:\n            dt = datetime.datetime.fromisoformat(ts.replace(\"Z\", \"+00:00\"))\n            if dt.tzinfo is None:\n                dt = dt.replace(tzinfo=datetime.timezone.utc)\n        except Exception:\n            continue\n        r[\"_dt\"] = dt\n        records.append(r)\n\n# 既知 agent 一覧（ローカル定義の md ファイル名から推定）\nknown_agents = set()\nif os.path.isdir(agents_dir):\n    for fp in glob.glob(os.path.join(agents_dir, \"*.md\")):\n        known_agents.add(os.path.splitext(os.path.basename(fp))[0])\n\nfor w in windows:\n    try:\n        td = parse_window(w)\n    except Exception as e:\n        print(f\"[skip {w}]: {e}\")\n        continue\n    cutoff = now - td\n    recent = [r for r in records if r[\"_dt\"] >= cutoff]\n    counts = Counter(r.get(\"subagent_type\", \"\") for r in recent if r.get(\"subagent_type\"))\n    errors = Counter(r.get(\"subagent_type\", \"\") for r in recent if r.get(\"status\") == \"error\")\n\n    print(f\"\\n=== Agent usage (last {w}) ===\")\n    print(f\"total invocations: {len(recent)}  unique types: {len(counts)}\")\n    if counts:\n        print(\"\\nTop 10:\")\n        print(f\"  {'agent':<40} {'calls':>6}  {'errors':>6}\")\n        for name, n in counts.most_common(10):\n            err = errors.get(name, 0)\n            print(f\"  {name:<40} {n:>6}  {err:>6}\")\n\n    if known_agents:\n        used = set(counts.keys())\n        unused = sorted(known_agents - used)\n        print(f\"\\n0-call agents (defined locally but not used in {w}): {len(unused)}\")\n        for name in unused[:30]:\n            print(f\"  - {name}\")\n        if len(unused) > 30:\n            print(f\"  ... and {len(unused) - 30} more\")\n    else:\n        print(f\"\\n(no local agents dir at {agents_dir}; cannot list 0-call agents)\")\nPY\n```\n\nThree design points.\n\n**Multiple windows can be compared in one command.** Run `agent-usage-summary.sh 7d 30d`\n\nand the 7-day and 30-day results print back to back. Trend shifts like \"used in the last 30 days but zero in the last 7\" are visible at a glance.\n\n**Known agents are cross-referenced via glob.** Filenames under\n\n`~/.claude/agents/*.md`\n\nwith the extension stripped are treated as \"defined agents.\" Add a new agent and it's automatically picked up without touching the script.**Error counts are output at the same time.** Records with `status: \"error\"`\n\nare tallied separately, so agents that are \"called but failing every time\" become visible too. Tracking success rate, not just call count, lets you catch a different class of problem: \"running but broken.\"\n\nThe script's output has two blocks: the **Top 10 ranking** and the **0-call list**.\n\n```\n=== Agent usage (last 30d) ===\ntotal invocations: 23  unique types: 3\n\nTop 10:\n  agent                                     calls  errors\n  Explore                                      19       0\n  general-purpose                               3       0\n  code-reviewer                                 1       0\n\n0-call agents (defined locally but not used in 30d): 7\n  - INDEX\n  - architect\n  - database-reviewer\n  - planner\n  - python-reviewer\n  - security-reviewer\n  - typescript-reviewer\n```\n\nWhat the ranking tells you is simple. Of 23 invocations, 19 (82.6%) are `Explore`\n\n. `Explore`\n\nis a general-purpose agent specialized in file search and code investigation, a built-in Claude Code feature rather than a custom definition. In other words, the numbers confirm that \"I defined seven custom agents, but in practice only the generic built-ins were used\" had been going on for three months.\n\nFor each agent that appears on the 0-call list, there are three choices.\n\n**Delete it.** If it's clearly unused and no calling mechanism has been built, delete it. You immediately save system prompt tokens and make the environment easier to reason about.\n\n**Archive it.** If it might be useful in the future but isn't needed now, move it to `~/.claude/agents/archive/`\n\n. Because the `glob`\n\npattern is restricted to `*.md`\n\n, moving a file into a subdirectory automatically drops it from the tally.\n\n**Build a caller.** If the agent's functionality is genuinely valuable and it's simply \"not being called,\" add a mechanism that explicitly invokes it, via a stop hook or a specific prompt pattern. Even here, without numbers you're just \"assuming it's valuable,\" so re-run the tally after implementing and confirm the effect.\n\nThe first half gave the big picture. Now I'll read through the code of the two scripts, stop_agent_tracker.sh (recording) and agent-usage-summary.sh (aggregation), focusing on \"why it's written this way\" and \"where the crux is.\"\n\nWhat the stop hook receives is JSON like the following, which Claude Code streams to standard input at session end:\n\n```\n{\"session_id\":\"36b40280-...\",\"transcript_path\":\"/.../.claude/projects/.../transcript.jsonl\",\"cwd\":\"/dev/note-autolike\",\"hook_event_name\":\"Stop\"}\n```\n\n`transcript_path`\n\npoints to the full conversation log for that session. Which agents were called is recorded there. But a single agent invocation is recorded as **two separate lines**: the `tool_use`\n\nat call time (type and arguments) and the `tool_result`\n\nafter completion (success/failure and output). Only by matching those two lines do you learn \"what, when, and did it succeed.\"\n\nThat's why the script uses two-pass processing.\n\n``` php\n# 第1パス: 全 tool_use と tool_result をインデックス化\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        for b in content:\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                uses[uid] = (ts, b.get(\"name\"), inp, b.get(\"caller\"))\n            elif btype == \"tool_result\":\n                results[rid] = (ts, bool(b.get(\"is_error\")))\n```\n\nThe first pass scans every line of the transcript and accumulates `uses`\n\nand `results`\n\nin separate dictionaries. The second pass iterates over `uses`\n\n, looks up the matching entry in `results`\n\n, and writes out JSONL.\n\nThere's one important design decision here. **Unmatched tool_uses (no key in results) are recorded with status: \"pending\".**\n\n```\nres = results.get(uid)\nif res:\n    res_ts, is_error = res\n    status = \"error\" if is_error else \"ok\"\nelse:\n    res_ts, status = None, \"pending\"\n```\n\nClaude Code fires the hook at session end. If a session was force-killed midway, or the session dropped before the tool_result was written, the record stays `pending`\n\n. This lets you distinguish \"recorded but never completed\" calls.\n\n**Computing duration_ms** is another benefit of the two-pass structure.\n\n```\nt0 = parse_ts(use_ts)   # tool_use のタイムスタンプ\nt1 = parse_ts(res_ts)   # tool_result のタイムスタンプ\nif t0 and t1:\n    duration_ms = int((t1 - t0).total_seconds() * 1000)\n```\n\nThe difference between the tool_use and tool_result timestamps is the agent's execution time. Looking at the real logs, `Explore`\n\ntakes 236ms while `general-purpose`\n\ntakes 3407ms to 6830ms. That gap roughly approximates the gap in token cost.\n\n**Deduplication logic** is also worth noting. A stop hook can fire multiple times in a single session (Claude Code restarts, reconnecting after a force-kill, and so on). Writing the same tool_use_id twice would corrupt the tally.\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            if r.get(\"session_id\") == sid and r.get(\"tool_use_id\"):\n                seen_ids.add(r[\"tool_use_id\"])\n```\n\nBefore writing, it scans every line of the JSONL and collects the `tool_use_id`\n\ns within the same `session_id`\n\ninto a set. The write loop skips anything in `seen_ids`\n\n. The fact that 682 records contain not a single duplicate is proof this works.\n\nLooking at this design, some people will think \"just write it all in Python.\" I thought so too at first.\n\nI kept the Bash shell for two reasons.\n\n**First: flexible argument handling.** Using `\"$@\"`\n\nlets you handle multiple arguments like `7d 30d`\n\nnaturally. In Python you'd have to parse `sys.argv`\n\nyourself, and dealing with the array is a bit more awkward.\n\n**Second: passing data via environment variables.** The Python inside the heredoc (`<<'PY'`\n\n) receives Bash-side variables through `os.environ`\n\n.\n\n```\nexport LOG_PATH=\"$LOG\"\nexport AGENTS_DIR_PATH=\"$AGENTS_DIR\"\nexport WINDOWS_CSV=\"$(IFS=,; echo \"${WINDOWS[*]}\")\"\n```\n\nThe `WINDOWS_CSV`\n\nconstruction, `IFS=,; echo \"${WINDOWS[*]}\"`\n\n, is the key. It converts the Bash array into a comma-separated string before handing it to Python, which then `.split(\",\")`\n\ns it back. Bash arrays can't be passed directly into a heredoc, so you need this bridge that encodes them as a string first.\n\nOn the Python side, `parse_window`\n\nconverts strings into `timedelta`\n\ns.\n\n``` python\ndef parse_window(s):\n    s = s.strip().lower()\n    if s.endswith(\"d\"):\n        return datetime.timedelta(days=int(s[:-1]))\n    if s.endswith(\"h\"):\n        return datetime.timedelta(hours=int(s[:-1]))\n    raise ValueError(f\"bad window: {s}\")\n```\n\nRight now it supports only `d`\n\n(days) and `h`\n\n(hours). If you want to add `w`\n\n(weeks) or `m`\n\n(months), changing just this function is enough for the whole script to work.\n\n**Automatic detection of known agents** is another important piece of the design.\n\n```\nknown_agents = set()\nif os.path.isdir(agents_dir):\n    for fp in glob.glob(os.path.join(agents_dir, \"*.md\")):\n        known_agents.add(os.path.splitext(os.path.basename(fp))[0])\n```\n\nIt puts every `*.md`\n\nfilename (minus the extension) directly under `~/.claude/agents/`\n\ninto a set. In my environment, moving a file into the `archive/`\n\nsubdirectory is enough to drop that agent from the tally. No script changes required. Older agent definitions currently live in the `archive/`\n\ndirectory (created August 29, 2024).\n\nHere's what it took to actually get this working. Every one of these was a \"it should work, so why is nothing being recorded?\" problem, and it took a while to get from symptom to cause.\n\nThe code I wrote first searched the transcript for `name == \"Task\"`\n\n. Claude Code's public-facing API had introduced agent invocation under the name \"Task.\"\n\nBut when I actually opened the transcript and checked its contents, every record said `\"name\": \"Agent\"`\n\n.\n\n```\n{\"type\": \"tool_use\", \"name\": \"Agent\", \"input\": {\"subagent_type\": \"Explore\", ...}}\n```\n\nBecause I was filtering on `name == \"Task\"`\n\n, every record was skipped and nothing was recorded for two days. I only noticed after checking directly with `grep '\"name\"' ~/.claude/projects/*/transcript.jsonl | head -5`\n\n. A gap between the documentation and the actual file.\n\nThe fix was one line.\n\n```\n# 修正前\nif btype == \"tool_use\" and b.get(\"name\") == \"Task\":\n# 修正後\nif btype == \"tool_use\" and b.get(\"name\") == \"Agent\":\n```\n\nThe lesson: don't trust the docs, read the actual file. transcript.jsonl is ordinary JSONL, so you can always inspect it directly.\n\n`subagent_type`\n\nlives at `input.subagent_type`\n\nThis one came from not pinning down the exact structure of tool_use. At first I tried to fetch it with `b.get(\"subagent_type\")`\n\n. That always returns `None`\n\n.\n\nRe-checking an actual transcript record, it looks like this:\n\n```\n{\n  \"type\": \"tool_use\",\n  \"id\": \"toolu_01RjC237NX1QwsWzVUMqHbvY\",\n  \"name\": \"Agent\",\n  \"input\": {\n    \"subagent_type\": \"Explore\",\n    \"description\": \"Survey note paid-article infra\",\n    \"prompt\": \"...\"\n  }\n}\n```\n\n`subagent_type`\n\nis inside `input`\n\n. The correct access is `b.get(\"input\", {}).get(\"subagent_type\")`\n\n. The current code pulls `input`\n\nout first with `inp = b.get(\"input\") or {}`\n\nand then reads `inp.get(\"subagent_type\")`\n\n.\n\nWhen the problem hit, the log that should have had 682 records had zero. The guard clause `if \"subagent_type\" not in inp: continue`\n\nwas rejecting every single one. To debug, I set the `CC_AGENT_TRACKER_DEBUG=1`\n\nenvironment variable to enable debug logging.\n\n```\nCC_AGENT_TRACKER_DEBUG=1 bash ~/.claude/hooks/stop_agent_tracker.sh <<< '...'\n```\n\n`stop_agent_tracker.log`\n\nshowed `recorded=0 total_uses=0`\n\n, confirming that \"not a single tool_use was being recognized.\" So I opened the transcript's raw JSON directly, checked the structure of input, and fixed it in one line.\n\n`set -uo pipefail`\n\ndies when the WINDOWS array is empty\n`set -uo pipefail`\n\nturns any reference to an undefined variable into an immediate error. That's the right setting in itself, but when called without arguments, the script tried to reference `${WINDOWS[*]}`\n\nbefore `${#WINDOWS[@]}`\n\ncould return 0, and errored out.\n\nConcretely, the original code was this:\n\n```\nWINDOWS=(\"$@\")\nexport WINDOWS_CSV=\"$(IFS=,; echo \"${WINDOWS[*]}\")\"  # 空配列で問題発生\n```\n\nCall it with no arguments and `WINDOWS`\n\nis an empty array. Under the `-u`\n\nflag, expanding an empty array can be an error (the behavior differs subtly between zsh and bash), so `WINDOWS_CSV`\n\nends up empty or, worst case, the script exits.\n\nThe fix is to set a default before exporting `WINDOWS_CSV`\n\n.\n\n```\nWINDOWS=(\"$@\")\nif [ ${#WINDOWS[@]} -eq 0 ]; then\n    WINDOWS=(\"7d\")\nfi\nexport WINDOWS_CSV=\"$(IFS=,; echo \"${WINDOWS[*]}\")\"\n```\n\nDo the empty check first, assign the default, then export. Under `set -u`\n\nyou just follow one simple rule: always settle a variable's value before using it.\n\nThis problem only surfaced when I ran it automatically from cron. Manual runs always passed arguments, so I never noticed, but the cron definition had no arguments, so it silently failed every night. I only realized \"there are nights with zero records\" after checking `tail -20 /var/log/...`\n\n.\n\nThe first record in the log has the session ID `TEST-AGENT-TRACKER-001`\n\n, another artifact of the debugging process. The record from when I manually fed dummy data to test whether the hook worked is still sitting at the head of the 682 entries. Mixing production logs with test data is untidy, but the aggregation logic filters by timestamp, so there's no real harm.\n\n`code-reviewer`\n\ndoesn't read the \"MUST BE USED\" in its own definition\nThis isn't a technical bug but a snag born of a fundamental misunderstanding.\n\nThe description at the top of `code-reviewer.md`\n\nsays `MUST BE USED for all code changes`\n\n. When I first defined the agent, I believed \"now a review runs automatically on every code change.\"\n\nYet in the actual 30-day tally, `code-reviewer`\n\nwas called exactly once.\n\nRe-examining how Claude selects agents: the description is \"a hint for deciding which agent to pick,\" not \"a command to force-call this agent.\" Unless the calling prompt or a hook specifies it explicitly, Claude takes the generic route.\n\nStrong wording like `MUST BE USED`\n\nfunctions as **a rule to be followed inside the agent once it has been selected**. It has no effect on forcing activation from outside the agent.\n\nOnce you understand that, there are two options. Either (1) bake an instruction like \"use code-reviewer after code changes\" into a stop hook or prompt template, or (2) \"if it's never going to be used, delete it.\"\n\nI'm currently choosing (2). `code-reviewer`\n\nwas called once in 30 days, and that one time was when I specified it manually. Without a mechanism, the call count won't rise. I judged that the cost of a 323-line definition file permanently occupying the system prompt outweighed the expected value of \"might use it someday.\"\n\nI could make that call only because I had the numbers. Without the fact of \"once in 30 days,\" the vague hope of \"maybe it's working\" would have lingered forever.\n\nTo sum up the implementation and the failures.\n\nThe crux of this system, which is complete in two scripts, is maintaining a state where \"real numbers come out whenever I want to check.\" With numbers you can decide. Without them you keep running on hope, and wasted tokens and system prompt bloat quietly pile up.\n\nBuild the mechanism that measures whether things get called before you add more definition files. That ordering is the basic posture for growing Claude Code into an autonomous environment.\n\nThe earlier sections covered four snags. Here I'll list additional pitfalls, grouped by \"environment-specific,\" \"operational phase,\" and \"misinterpretation.\" Only ones I actually stepped on.\n\n**PATH is dead when running from cron.**\n\nIt works when run manually, but fails with `python3: command not found`\n\nwhen run from a cron definition. The cron execution environment doesn't load `~/.zshrc`\n\nor `~/.profile`\n\n, and `PATH`\n\nis roughly `/usr/bin:/bin`\n\n. `/usr/local/bin/python3`\n\nand node under nvm become invisible. Two remedies: hardcode `PATH`\n\nat the top of the script, or write `PATH=/usr/local/bin:/usr/bin:/bin`\n\non the first line of the cron definition. I use the latter. Since `HOME`\n\nmay also be unset, you need to write the absolute path `/Users/youraccount/`\n\nin the cron entry instead of `~/`\n\n(the `$HOME`\n\ninside the script itself is fine as long as the `HOME`\n\nenvironment variable is set).\n\n**Don't let one broken JSONL line stop the whole script.**\n\nWith 682 accumulated records, an incomplete JSON line can sneak in. If the session drops while Claude Code is invoking the hook, the last record can end up half-written. If you write `json.loads`\n\nwithout a try/except, a single corrupt line kills the whole script. The aggregation script's current code skips with `except Exception: continue`\n\n, ignoring the broken line and processing the rest. Skip the try/except because \"that case will never happen,\" and it'll fail for the first time three months later once the log has grown.\n\n`glob(\"*.md\")`\n\npicking up the archive directory.\n\n`glob.glob(os.path.join(agents_dir, \"*.md\"))`\n\ntargets only `.md`\n\nfiles **directly** under `~/.claude/agents/`\n\n. Files moved into the `archive/`\n\nsubdirectory are not included, which is intended. But if you rewrite it as `glob.glob(os.path.join(agents_dir, \"**/*.md\"), recursive=True)`\n\n, archived agents are treated as \"defined\" again and reappear on the 0-call list. You get an \"I archived it but it hasn't gone away\" situation. The correct answer is not to add `recursive=True`\n\n.\n\n`INDEX.md`\n\nis misdetected as an agent.\n\nMy `~/.claude/agents/INDEX.md`\n\nis not an agent definition but an index file for the directory. Since `glob(\"*.md\")`\n\nlooks only at the extension, the name `INDEX`\n\ngets counted as a \"defined agent.\" As a result, `INDEX`\n\nalways shows up on the 0-call list. There's no real harm, but every time I see the list I pay the cost of thinking \"what was this again?\" Remedies: move the INDEX file to a subdirectory, keep an exclusion list in the script, or don't put such files there in the first place. For now I've left it and mentally filed \"INDEX at zero is normal.\"\n\n**It's not obvious why Explore and general-purpose never appear on the 0-call list.**\n\nThe 0-call list only shows \"things with a file in `~/.claude/agents/`\n\nthat weren't called.\" `Explore`\n\nand `general-purpose`\n\nare Claude Code built-in agents with no local `.md`\n\nfile. They aren't in `known_agents`\n\n, so they never appear on the 0-call list. That's by design, but it confused me at first: \"why doesn't Explore show up?\" You need to read the list knowing that built-in agents dominate the top of the Top 10.\n\n**There are cases where the stop hook doesn't run.**\n\nThe stop hook fires on normal session termination. It doesn't fire on Ctrl+C force-quits, process kills, or Claude Code crashes. So you get \"that long session wasn't recorded.\" I can't tell how many of the 682 are missing, but I operate on the premise that \"I can analyze what was recorded.\" Demanding perfect records stops operations.\n\n`pending`\n\nstatus records get mixed into the tally.\n\nRecords with `status: \"pending\"`\n\nwere recorded mid-session. The aggregation script doesn't filter by status and counts everything. That means \"started but unknown whether completed\" calls are included in the call count. Checking the actual breakdown of the 682 records, pending is a tiny minority, but if you want more precision you need to add a `\"status\" != \"pending\"`\n\ncondition. Current policy is \"tolerate a little error.\"\n\n**Misreading the error rate.**\n\nThe `errors`\n\ncolumn in the Top 10 is all zeros, but that doesn't mean no errors ever occurred. Only records with `status: \"error\"`\n\ncount as `errors`\n\n. Outcomes like \"the answer was incomplete\" or \"it couldn't find the file\" are all recorded as `ok`\n\n. \"Few errors = running healthily\" is overstating it. Read it as \"few fatal errors at the recording level.\"\n\n`cwd`\n\nvalues get mixed across multiple projects.\n\nEach JSONL record includes `cwd`\n\n. My log has `/dev/affiliate-fc2`\n\n, `/dev/note-autolike`\n\n, `/dev/...`\n\nall mixed together. The current aggregation script doesn't distinguish projects and sums everything. If you want analysis like \"project A uses Explore heavily but project B never does,\" you need to add an option to filter on the `cwd`\n\nfield. For now the company-wide total is enough, so it's unimplemented, but I plan to add it as the number of projects grows.\n\n**Underestimating the size of definition files.**\n\nIt's easy to shrug off 0-call agents with \"eh, whatever,\" but the numbers change your view. The current eight files total 109,852 bytes (about 107KB) and 1,221 lines. The big ones are `code-reviewer.md`\n\n(323 lines), `planner.md`\n\n(221 lines), and `architect.md`\n\n(220 lines). These get injected into the system prompt on every request. How much 107KB matters depends on how you use the overall context window, but you really do feel \"responses got faster after deleting unused definitions.\" Putting a number on it is what finally gets you moving.\n\nHere's what proved effective in actual operation, along with lessons learned from failures.\n\n**1. Check the log format in the raw file before writing the script.**\n\nRun `grep '\"name\"' ~/.claude/projects/*/transcript.jsonl | head -5`\n\nfirst. Whether it's `\"Task\"`\n\nor `\"Agent\"`\n\n, and which level `subagent_type`\n\nsits at, the actual file is the truth, not the docs. This one command prevents two wasted days.\n\n**2. Don't believe \"the agent I defined is working\" until you've measured it.**\n\nThe phrase `MUST BE USED`\n\nis an internal rule for after the agent has been selected. It has no effect on forced invocation from outside the agent. Right after defining, run `agent-usage-summary.sh 7d`\n\n, and if it's still at zero a week later, decide immediately: build a caller or delete it.\n\n**3. Always show the 7-day and 30-day windows side by side.**\n\nThe `agent-usage-summary.sh 7d 30d`\n\ncombination shows both \"recently started using\" and \"used before but not lately\" in one command. An agent called just once in 30 days shows zero in the 7-day tally. That gap tells you it was \"a one-off, incidental use.\"\n\n**4. Don't be afraid to delete. Use the archive selectively.**\n\nThe \"might use it someday\" thought pattern preserves unneeded agents. Keep the criterion simple: \"zero calls in 30 days and no calling mechanism exists means delete; otherwise archive.\" Archiving is just moving to `~/.claude/agents/archive/`\n\n, and you can restore it if needed. Since glob only looks at the top level, it drops out of the tally the moment you move it.\n\n**5. Re-run the tally right after deleting to confirm the effect.**\n\nAfter deleting an agent, run `agent-usage-summary.sh 30d`\n\nand confirm the 0-call list got shorter. If \"I deleted it but it's still there,\" the file remains or there's a copy elsewhere. The habit of comparing numbers before and after each change keeps the environment trustworthy.\n\n**6. Make the stop hook's records manually checkable after a session ends.**\n\n`tail -5 ~/.claude/logs/agent-invocations.jsonl | python3 -m json.tool`\n\nlets you check the latest five records any time. The habit of confirming \"was today's session recorded?\" doubles as a liveness check on the hook. No records for a week is a sign the hook is broken.\n\n**7. If the error count suddenly rises, investigate it first.**\n\nIn normal times the error count is zero. If a number appears in the `errors`\n\ncolumn of the Top 10 table, that agent is having problems. Extract the records with `grep '\"status\":\"error\"' ~/.claude/logs/agent-invocations.jsonl`\n\n, then trace the transcript from the `session_id`\n\nto find out what happened. Monitoring the error rate matters as much as monitoring usage.\n\n**8. Tally pending status separately to understand the loss rate.**\n\nPeriodically check `grep '\"status\":\"pending\"' ~/.claude/logs/agent-invocations.jsonl | wc -l`\n\n. If the pending count among the 682 is trending up, either there are many force-quits (unstable sessions) or the hook is dying partway through. Under 10% loss is acceptable; above that, start investigating.\n\n**9. Explicitly set HOME and PATH in cron definitions.**\n\n```\n0 9 * * 1 HOME=/Users/自分/ PATH=/usr/local/bin:/usr/bin:/bin bash ~/claude/scripts/agent-usage-summary.sh 7d 30d >> ~/agent-weekly.log 2>&1\n```\n\n`HOME`\n\ncan't expand `$HOME`\n\n, so hardcode the value. `PATH`\n\nmust include wherever `python3`\n\nis visible. Redirecting output to `>> ~/agent-weekly.log`\n\ntells you why it failed when it does.\n\n**10. Keep the tallies as a weekly report.**\n\nAdd `agent-usage-summary.sh 7d 30d >> ~/.claude/logs/agent-weekly-report.log`\n\nto a weekly cron and accumulate the log. A change like \"two months ago `python-reviewer`\n\nwas called five times a month, now it's zero\" can reflect library changes or a shift in the kind of work. Being able to track changes over time lets you review your agent design.\n\n**11. Don't define an agent before building the calling logic.**\n\n\"Define first, think about callers later\" is the root of the problem. When you define an agent, decide at the same time \"when and how will this be called?\" A hook, a prompt template, automatic execution after a specific command. Unless one of those exists, the definition file only bloats the system prompt.\n\n**12. Check existing agents' call counts before adding a new one.**\n\nRun `agent-usage-summary.sh 30d`\n\nbefore adding, and if the 0-call list is long, tidy up first. Maintain an environment where \"only the things being used exist,\" not \"the old ones stopped being used after I added new ones.\" The smaller the total volume of definition files, the clearer Claude Code's agent selection becomes.\n\n**13. Use duration_ms to identify heavy agents.**\n\n`Explore`\n\naverages 236ms; `general-purpose`\n\nruns 3,407ms to 6,830ms. If a heavy agent is called frequently, it's worth considering whether `Explore`\n\ncould cover that use case instead. Since duration_ms is recorded in the JSONL, you can compute mean and median with `python3 -c \"import json,statistics; data=[json.loads(l) for l in open('~/.claude/logs/agent-invocations.jsonl')]; ...\"`\n\n.\n\nThe \"defined = working\" fallacy produces the quietest cost of all when growing a Claude Code environment.\n\nIn this measurement, of eight agent definitions, only one type was called in 30 days, and only once. The remaining seven, a combined 1,100 lines and roughly 95KB of definitions, kept occupying the system prompt on every request. Two scripts made this visible: stop_agent_tracker.sh records agent invocations to JSONL, and agent-usage-summary.sh aggregates by window and agent and outputs the 0-call list.\n\nThree key points about the mechanism.\n\n**Two-pass processing of transcript.jsonl.** tool_use and tool_result are recorded on separate lines. Only by matching them do you get \"what, when, and did it succeed\" together. Write it as a single pass and you only get one side, leaving incomplete records.\n\n**Division of labor between a Bash shell and a Python core.** Leave argument handling and environment variable passing to Bash, and JSONL parsing and aggregation to Python. Try to write it in just one and you'll get stuck on the part that language isn't good at.\n\n**Cross-referencing against ~/.claude/agents/*.md.** The 0-call list the tally produces can't be built from logs alone. Detecting \"defined but not called\" requires information from the filesystem side. That single step of grabbing filenames via glob and taking the set difference is the heart of this script.\n\nDeleting isn't scary. Moving to the archive drops it from the tally, and you can bring it back if needed. \"Without numbers you can't decide; with numbers you don't hesitate.\" That's the basic posture for growing an autonomous environment.\n\nWhat supports ¥1.2M/month isn't the intelligence of the AI. It's an environment where you can measure whether things are running. Because you can measure, you can cut. Because you can cut, the AI can focus on its real work. Two scripts keep that cycle turning.\n\nHow many of your own custom agents would survive a 30-day zero-call check?\n\nThe full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are compiled in a paid note.\n\n📕 [How to actually earn with a Claude Code autonomous environment: the system, real examples, getting started, and support](https://note.com/bokuwalily/n/n849b3a07784a)\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/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents", "canonical_source": "https://dev.to/bokuwalily/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents-automatically-27jf", "published_at": "2026-09-02 00:00:04+00:00", "updated_at": "2026-09-02 00:23:33.978821+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "mlops"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents", "markdown": "https://wpnews.pro/news/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents.md", "text": "https://wpnews.pro/news/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents.txt", "jsonld": "https://wpnews.pro/news/7-of-my-8-claude-code-agents-had-zero-calls-in-30-days-finding-dead-agents.jsonld"}}