12 Pitfalls I Hit Auto-Logging Claude Code Subagents with a Stop Hook (and How the Numbers Cut My Weekly Cost 15–20%) A developer built a post-session logging system for Claude Code using the Stop hook, parsing transcript JSONL files to track subagent invocation times, error rates, and costs. Measuring subagent performance revealed that the code-reviewer agent averaged 37 seconds versus 18 seconds for general-purpose and 22 seconds for Explore, and a single habit change based on that data cut weekly Claude Code spend by roughly 15–20%. The setup requires only one line in ~/.claude/settings.json and leaves Claude Code's behavior unchanged. I had a gut feeling that my general-purpose subagent was the slow one. Then I measured it: 18 seconds on average, faster than Explore at 22. The real drag was code-reviewer at 37 seconds. Changing one habit based on that number dropped my weekly Claude Code spend by roughly 15–20%. I made a couple hundred thousand yen a month as a student juggling side gigs, got laid off and went back to zero, and spent six months building an autonomous Claude Code setup that now runs at about ¥1.2M in monthly revenue. The foundation of that setup is post-session log collection driven by a Stop hook. This post covers the implementation, the 12 pitfalls I hit along the way, and the jq recipes I use every week. After using Claude Code for a while, a nagging feeling sets in: "I have no idea where the cost is going." You mix Explore agents, code-reviewer agents, general-purpose agents, and before you know it a session has been running for tens of minutes. But which agent is slow? Which one errors most often? I had never seen a number. Saying "that agent feels heavy" is not data. Without data, you can't tell what to fix. For someone shipping systems solo at volume, that's a fatal blind spot. Optimizing a workflow starts with measuring it. Claude Code ships with a mechanism that lets you automate that measurement: the Stop hook . A Stop hook is a shell script that runs every time a Claude Code session ends. The hook receives a transcript path — the path to the raw JSONL conversation log for the entire session, including tool calls, agent launches, and response timestamps. In other words, every time a session ends, you automatically get a chance to read "everything that happened in this session." Once I noticed that, my thinking changed. There's no need to keep records by hand. Trigger a script on session end, parse transcript path , and write a list of subagent invocations out to JSONL. That alone accumulates "which agent took how many seconds, and did it succeed or fail." Then aggregate the ledger with jq , and you move from gut feelings to a conversation about numbers. One more important point: this approach touches nothing in Claude Code itself. A Stop hook is enabled by adding a single line to ~/.claude/settings.json . It doesn't change the original behavior; it just piggybacks on an existing event, session end. Near-zero side effects is a big advantage when you're maintaining a high-volume environment. Of the JSON the Stop hook receives, two fields matter. { "session id": "...", "transcript path": "/path/to/transcript.jsonl" } The file at transcript path contains every message exchanged in the session in JSONL format one JSON object per line . Inside each line's message.content , you'll find "type": "tool use" blocks representing tool calls. Subagent launches are recorded here as "name": "Agent" . Note that even though Claude Code's UI displays "Task", the tool name in the transcript is "Agent" the script's comment spells this out: "In Claude Code transcripts, the 'Task' tool is recorded as name="Agent"" . The subagent type lives in input.subagent type . The result of an agent call appears on a separate line as "type": "tool result" , linked to the call via tool use id . If is error: true is present, it ended in error; otherwise, it succeeded. Once you understand this structure, a two-pass Python script is all it takes to extract subagent execution records. Here's the whole system as a diagram. Claude Codeセッション ┌─────────────────────────────────────────────────┐ │ tool use name="Agent", subagent type="Explore" │ │ ...処理中... │ │ tool result tool use id=xxx, is error=false │ └─────────────────────────────────────────────────┘ ↓ セッション終了 Stop イベント ┌─────────────────────────────────────────────────┐ │ Stop hook: stop agent tracker.sh │ │ stdin: {"session id", "transcript path", ...} │ └─────────────────────────────────────────────────┘ ↓ transcript path を読み込む ┌─────────────────────────────────────────────────┐ │ Python: 2パス解析 │ │ Pass1: uses{} / results{} を構築 │ │ Pass2: tool use id で突き合わせ・duration算出 │ └─────────────────────────────────────────────────┘ ↓ 追記 ~/.claude/logs/agent-invocations.jsonl When the Stop hook fires, it receives JSON on stdin , and a Python script reads the transcript using the transcript path inside it. The output destination is fixed: ~/.claude/logs/agent-invocations.jsonl . The script is almost entirely Python. The shell portion only handles passing environment variables. INPUT=$ cat stdin から Stop イベント JSON を受け取る export STOP INPUT="$INPUT" export OUT LOG PATH="$OUT LOG" ~/.claude/logs/agent-invocations.jsonl The Python portion is split into three main blocks. Deduplication block If the Stop hook runs multiple times for the same session which can happen by Claude Code's design , we don't want to write the same entry twice. So we read the existing log and load session id + tool use id combinations into a seen ids set. seen ids = set if os.path.exists out path : with open out path, "r", ... 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" Pass 1: build the index Read the transcript line by line. tool use blocks with "name": "Agent" and a subagent type go into the uses dict; tool result blocks go into the results dict. php uses = {} tool use id - ts, name, input, caller results = {} tool use id - ts, is error for b in content: if b.get "type" == "tool use" and b.get "name" == "Agent": inp = b.get "input" or {} if "subagent type" not in inp: continue uses uid = ts, b.get "name" , inp, b.get "caller" elif b.get "type" == "tool result": results rid = ts, bool b.get "is error" Agent calls without a subagent type are skipped. This check exists to exclude invocations of the main Claude agent itself. Pass 2: match and write Iterate over uses , and if a matching results entry exists, compute duration ms and write it out. t0 = parse ts use ts tool use のタイムスタンプ t1 = parse ts res ts tool result のタイムスタンプ if t0 and t1: duration ms = int t1 - t0 .total seconds 1000 Entries whose result hasn't come back yet are recorded with "status": "pending" . The description field is truncated at 300 characters. This keeps the log file size under control even when the instructions given to an agent are long. if len description 300: description = description :300 + "…" A single line of the resulting JSONL looks like this. { "ts": "2026-09-10T08:30:00Z", "session id": "abc123", "cwd": "~/dev/my-project", "tool use id": "toolu 01Xyz...", "subagent type": "Explore", "description": "Find all TypeScript files that reference the Auth module…", "duration ms": 18420, "status": "ok", "caller": null } To summarize the fields: subagent type is the agent kind, duration ms is the execution time in milliseconds, and status is one of "ok" / "error" / "pending" . caller holds information about the caller, but it's null most of the time. When you read a tool use in a single pass, the matching tool result may not have appeared yet. JSONL is in chronological order, but during a long agent run, other messages can be interleaved. Reading every line first to build an index and matching afterward — the two-pass structure — guarantees you get the pairs. Also, when the parse ts function converts ISO 8601 timestamps to datetime objects, it replaces the trailing Z with +00:00 before passing to fromisoformat . python def parse ts s : try: return datetime.datetime.fromisoformat s.replace "Z", "+00:00" except Exception: return None Python's fromisoformat can't parse the Z suffix directly before 3.11, so this conversion is required. The script opens the file in "a" append mode. Each session adds new records to the end. If the file doesn't exist, it's created automatically guaranteed by mkdir -p on LOG DIR . Because data accumulates across sessions, after days or weeks of use, patterns emerge: "the Explore agent always takes over 20 seconds," "the code-reviewer agent errors out occasionally." Running alongside stop agent tracker.sh is stop cost log.sh . This one records the tokens consumed in a session and the estimated cost to ~/.claude/logs/cost-log.jsonl . Alongside agent slowness, knowing "when, in which session, and how much it cost" is essential for keeping a high-volume environment running. The rate table is implemented like this. PRICING = { "claude-opus-4-7": {"input": 15.0, "output": 75.0, "cache read": 1.5, "cache create 5m": 18.75, "cache create 1h": 30.0}, "claude-sonnet-4-6": {"input": 3.0, "output": 15.0, "cache read": 0.3, "cache create 5m": 3.75, "cache create 1h": 6.0}, "claude-haiku-4-5": {"input": 1.0, "output": 5.0, "cache read": 0.1, "cache create 5m": 1.25, "cache create 1h": 2.0}, } DEFAULT RATE = PRICING "claude-sonnet-4-6" def rate for model: str : for k, v in PRICING.items : if model.startswith k : return v return DEFAULT RATE The key point is that dictionary keys are matched by prefix startswith rather than exact match. Model names Claude Code writes to the transcript can carry a release-date suffix, like claude-sonnet-4-6-20250620 . With exact matching, you'd have to rewrite PRICING every time a minor version updates. Prefix matching absorbs suffix changes. Unknown models fall back to DEFAULT RATE Sonnet-equivalent , so aggregation doesn't stop when a model not in the table shows up. The Anthropic API has two kinds of cache: a 5-minute ephemeral cache and a 1-hour cache, priced differently roughly a 1:1.6 ratio . The script tallies them separately. cc 5m = usage.get "cache creation", {} or {} .get "ephemeral 5m input tokens", 0 or 0 cc 1h = usage.get "cache creation", {} or {} .get "ephemeral 1h input tokens", 0 or 0 if cc 5m + cc 1h == 0 and cc total 0: cc 5m = cc total The last three lines are a consistency check. In older Claude Code versions or under certain conditions, the nested cache creation object doesn't exist and only a flat cache creation input tokens is returned. That yields cc 5m + cc 1h == 0 with cc total 0 , so the full amount is treated as the 5-minute tier. Since the 5-minute tier is cheaper, this biases toward underestimating cost. I decided that's easier to reason about than overestimating. cost usd += inp / 1 000 000 r "input" + out / 1 000 000 r "output" + cr / 1 000 000 r "cache read" + cc 5m / 1 000 000 r "cache create 5m" + cc 1h / 1 000 000 r "cache create 1h" The unit is "$/MTok" dollars per million tokens , so the token count is divided by 1 000 000 before multiplying. The -separated numeric literals are for readability and work on Python 3.6+. The script walks every message in the session, accumulates cost usd , and finally rounds to four decimal places with round cost usd, 4 . Once the log builds up, you can ask it questions with jq . All of the following are real queries against ~/.claude/logs/agent-invocations.jsonl . Average and max execution time per agent type jq -s ' group by .subagent type | map { type: . 0 .subagent type, count: length, avg ms: map select .duration ms = null | .duration ms | add / length | round , max ms: map select .duration ms = null | .duration ms | max } | sort by -.avg ms ' ~/.claude/logs/agent-invocations.jsonl group by buckets by type, then avg ms and max ms are computed and sorted descending. When I actually ran this, my environment showed the Explore agent averaging around 22 seconds, code-reviewer averaging 37 seconds, and general-purpose averaging 18 seconds. code-reviewer is slow because it reads multiple files — and once that was confirmed by numbers, my hunch that "using code-reviewer for multi-file checks is heavy" finally had backing. Identify agents with high error rates jq -s ' group by .subagent type | map { type: . 0 .subagent type, total: length, errors: map select .status == "error" | length, error rate: map select .status == "error" | length / length 100 | round } | sort by -.error rate ' ~/.claude/logs/agent-invocations.jsonl Sum cost for a specific project cwd jq -s ' map select .cwd | contains "my-project" | { total cost: map .cost usd | add } ' ~/.claude/logs/cost-log.jsonl The cost log also has a cwd field, so you can filter by project path and get a total. Once you can see how much you've spent on which project at a glance, your sense of profitability changes. I hit four concrete snags before the implementation was done. All of them were the "why doesn't this work?" kind of bug, where the symptom alone hides the cause. In the first version, I wired shell and Python together like this. cat | python3 - <<'PY' data = json.load sys.stdin ← ここで空が返ってくる PY The misconception: "the heredoc Python script itself is passed via stdin, so I can read input from sys.stdin ." In reality, <<'PY' occupies stdin as far as the shell is concerned, so by the time the script runs, sys.stdin is already at EOF. Even if cat receives the Stop hook JSON, there's no route for it to reach Python. The fix is simple: "store it in a shell variable first, then pass it as an environment variable." INPUT=$ cat export STOP INPUT="$INPUT" python3 - <<'PY' data = json.loads os.environ.get "STOP INPUT", "" PY INPUT=$ cat reads the Stop hook JSON, and export turns it into an environment variable. Inside the heredoc Python, read it from os.environ . This detour avoids the stdin conflict. The real script takes this shape precisely because of this history. fromisoformat chokes on Z Transcript timestamps come in the form "2026-09-10T08:30:00Z" . At first I naively wrote datetime.fromisoformat ts . Python 3.10以前ではこれが ValueError になる datetime.datetime.fromisoformat "2026-09-10T08:30:00Z" macOS's system Python was 3.10, so this one line threw ValueError and the script died. I hadn't written a try/except , so the entire Stop hook exited with an error and no logs were written for several days. I had no idea why logs weren't being generated, and only found the cause after enabling debug logging with CC AGENT TRACKER DEBUG=1 . The fix lives inside the parse ts function. python def parse ts s : if not s: return None try: return datetime.datetime.fromisoformat s.replace "Z", "+00:00" except Exception: return None Replace Z with +00:00 before passing it in, and it works on Python 3.7+. It's also wrapped in try/except , so an unexpected format returns None instead of killing the script. The duration ms computation only runs "when both t0 and t1 are not None," so entries that returned None are recorded with duration ms: null . Give up on the calculation, but keep the record. A few days after enabling the Stop hook, I found duplicate entries with the same tool use id in the log. For example, after a session that ran for three hours, agent-invocations.jsonl had two lines with the same ID. Claude Code can emit the session end event more than once under certain conditions such as when a subprocess's exit overlaps with the main session's exit . So the Stop hook ran twice for the same session, and the second run read the same transcript and wrote the same records. The countermeasure was to add a dedup block at the top: "collect the tool use ids already written for this session into a set, and skip them next time." 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: try: r = json.loads line if r.get "session id" == sid and r.get "tool use id" : seen ids.add r "tool use id" except Exception: continue At write time, the check looks like this. for uid, use ts, , inp, caller in uses.items : if uid in seen ids: continue 既に記録済みならスキップ Identity is determined by the session id + tool use id combination because the probability of the same tool use id colliding across sessions isn't zero they're UUIDs, so it's extremely low, but just in case . Reading only records with the same session id , rather than the entire log, is a performance consideration. name="Task" returns nothing When I first investigated the transcript structure, the Claude Code interface showed "Task", so I wrote the script the same way. if btype == "tool use" and b.get "name" == "Task": Running this logged nothing. When I removed the filter for debugging and dumped every tool use , I discovered the actual transcript records it as "name": "Agent" . The UI display name and the tool name in the transcript don't match. It's an internal naming asymmetry in Claude Code that isn't documented. The comment in the real script — "In Claude Code transcripts, the 'Task' tool is recorded as name="Agent"" — exists because of this experience. I left it in the first place anyone would look so nobody falls into the same trap. The correct filter is a combination of two conditions. if btype == "tool use" and b.get "name" == "Agent": inp = b.get "input" or {} if "subagent type" not in inp: continue Only lines where name == "Agent" and input contains subagent type are targeted. Agent calls without a subagent type can be entries for the main Claude session itself, and the two-stage filter exists to exclude those. set -e dies silently and no logs remain at all The initial shell header was set -euo pipefail . -e makes the entire shell exit immediately when a command returns non-zero. The problem arose when running in a "session with no transcript." Claude Code's lightweight sessions ask a short question and exit sometimes don't generate a transcript. In that case os.path.exists tp returns False and Python exits with sys.exit 0 , but there's also a path where the earlier -z "$INPUT" && exit 0 exits the shell normally. With -e enabled, depending on how the && evaluates, the script could stop unintentionally. Also, calling sys.exit 0 inside the Python script is a normal exit code 0 and fine, but in some environments the python3 command itself can't be found not on PATH , returning exit code 127, and -e would stop the shell and skip all subsequent processing. The fix is to drop -e and use -uo pipefail . set -uo pipefail -u makes access to undefined variables an immediate error. pipefail makes the whole pipeline fail if any stage errors. -e is removed. The principle for a hook script is "even if I fall over, don't affect Claude Code's behavior," so bailing out proactively with exit 0 is safer. Errors are written to DEBUG LOG ~/.claude/logs/stop agent tracker.log , so I prioritized having the cause visible in the debug log over dying silently under -e . That covers the implementation details and the record of failures. The next part deals with how to fold these logs into daily operations, concrete examples of numbers changing, and a practical collection of jq recipes. The previous part covered five snags, but once you're in real operation, there are more. Listed in order of frequency. ⑥ python3 isn't found and the hook exits silently When Claude Code is launched from the GUI, it doesn't load the login shell and only has a minimal PATH, roughly /usr/bin:/bin . ~/.pyenv/shims/python3 and ~/.nvm/versions/.../bin/python3 aren't on it. The symptom: "the hook is registered but no log ever appears." With set -uo pipefail no -e , the subsequent script keeps running after a 127, but the output file stays empty. Two countermeasures: write export PATH="/usr/local/bin:/usr/bin:$HOME/.pyenv/shims:$PATH" at the top of the script, or pin the full path /usr/bin/python3 instead of /usr/bin/env python3 . On macOS Ventura and later, Xcode Command Line Tools provide /usr/bin/python3 , so if a system Python exists, the latter is the safest. ⑦ Some messages have content as a string rather than a list Most messages in transcript.jsonl have content as a list, but a handful of system messages have content as a string. Trying for b in content on those iterates character by character. The real script has a guard, if not isinstance content, list : continue lines 94–96 , but if you modify the script yourself and delete that line, it breaks. Checking whether content is a list on every line is an ironclad rule. ⑧ jq -s on tens of thousands of JSONL lines runs out of memory jq -s reads the entire file into memory before processing. Once agent-invocations.jsonl exceeds tens of thousands of lines, a query eats several hundred MB of your Mac's memory. At a few hundred sessions a month, you get there in 3–4 months. The solution is the --stream option, or periodic archiving. I move logs monthly into ~/logs/archive/agent-YYYY-MM.jsonl and keep only the last 30 days in agent-invocations.jsonl . 月次ローテーション(launchdで自動実行) MONTH=$ date -v-1m +%Y-%m jq -c 'select .ts | startswith "'"$MONTH"'" ' \ ~/.claude/logs/agent-invocations.jsonl \ ~/.claude/logs/archive/agent-${MONTH}.jsonl ⑨ status: "pending" entries pile up If Claude Code is force-quit while a subagent is running ⌘Q, OS shutdown, OOM kill , the transcript ends without a tool result being written. When the Stop hook runs in this state, no matching results exist, so the entry is recorded with status: "pending" . That's correct behavior in itself, but once dozens of pending entries accumulate per month, average execution time aggregates get skewed you need to filter duration ms: null entries with select .duration ms = null . Always adding select .status == "ok" to monthly aggregate queries is the safe move. ⑩ The dedup block gets slow on a huge log The dedup check at session start reads every line of the existing log looking for records whose session id matches lines 58–69 . Once the log exceeds 100,000 lines, the hook takes several seconds per session. Two practical countermeasures. First, partition the JSONL by session date. Second, limit the seen ids read to "only the last N lines" entries from the same session aren't guaranteed to be contiguous, so it's not a complete fix, but it's a realistic compromise . ⑪ Real paths land in cwd and you can't publish the script The JSONL stop agent tracker.sh outputs contains "cwd": "/Users/realname/dev/my-project" . Forget to add this log to .gitignore , commit it to a repository, and your home directory's absolute path leaks. ~/.claude/logs/ sits outside any repo, so that's fine, but when handing logs to an analysis script, people sometimes push the whole log file to a gist or a shared folder. Either process it with jq 'del .cwd ' before sharing, or add a preprocessing step that reduces the cwd field to just the directory name basename . ⑫ Burning days without knowing how to enable debug logging stop agent tracker.sh implements an environment variable switch, CC AGENT TRACKER DEBUG=1 line 31 . Set it to 1 and detailed logs are written to ~/.claude/logs/stop agent tracker.log . stop cost log.sh does the same with CC COST DEBUG=1 , writing to ~/.claude/logs/stop cost log.log . The first step to confirming whether a hook is running is tail -f ~/.claude/logs/stop agent tracker.log . I personally burned days in a "can't figure out why no logs appear" state without knowing this. After setting up hooks in a new environment, running one session in debug mode to confirm it works is a mandatory step. Here are the rules that solidified through implementation and operation. 1. Hook scripts always end with exit 0 If a Stop hook exits non-zero, Claude Code may show a warning at the next session start. Even if something goes wrong inside the script, the ironclad design is: write it to the log and bail out with exit 0 . Removing set -e is for the same reason. Keep hook failures from spilling into Claude Code's own behavior. 2. Receive stdin on the first line with INPUT=$ cat and repack it into an environment variable There's no way to pass stdin to a heredoc Python script. Always use the INPUT=$ cat → export STOP INPUT → os.environ route. This is the direct lesson from pitfall ①. As long as you keep this shape, input will arrive no matter how much you modify the Python code. 3. Wrap the Python code in one big try/except , and write exceptions to the debug log instead of swallowing them try: メイン処理 ... except Exception as e: log f"unexpected error: {e}" sys.exit 0 Even if an exception occurs, it exits normally via sys.exit 0 . It's in the log. Claude Code is unaffected. The structure above achieves all three at once. 4. Confine timestamp handling to a parse ts function that returns None As the fromisoformat Z problem pitfall ② illustrates, timestamp processing is full of environment-dependent edge cases. Always isolate conversion in a dedicated function and return None on failure. If the duration ms computation is conditioned on "only when both t0 and t1 are not None," None never causes a TypeError. 5. Add deduplication from the start. Add it later and you can't clean the existing log Pitfall ③ multiple Stop hook invocations shows up days later as "why are there two lines of the same entry?" By then duplicates are buried in the existing log, and sort -u won't work as-is because JSONL key order isn't deterministic. The rule is to include dedup logic from the initial implementation. The cost is just "one pass reading the existing log." 6. Always include select .duration ms = null and select .status == "ok" in jq queries Averages that include pending entries duration ms: null get pulled toward zero. Add those two conditions as the default in aggregate queries, and you get accurate numbers based only on valid data. jq -s ' map select .duration ms = null and .status == "ok" | group by .subagent type | map {type: . 0 .subagent type, avg ms: map .duration ms | add / length | round } ' ~/.claude/logs/agent-invocations.jsonl 7. Archive log files monthly and keep the live file light When agent-invocations.jsonl grows, the dedup pass slows down pitfall ⑩ and jq -s eats memory pitfall ⑧ . Set up a monthly launchd archive job from the start and these problems never occur. In practice, most people archive once they notice "the log got heavy," but putting it in from the beginning makes operation easier. 8. Look up the PRICING table by prefix, not exact match The rate for implementation in stop cost log.sh is the best practice as-is. Looking up keys with model.startswith k absorbs variation in model names with release-date suffixes, like claude-sonnet-4-6-20250620 . Unknown models fall back to Sonnet-equivalent, so aggregation doesn't stop when a model not in the table appears. 9. After configuring a hook, always run one full session in debug mode to confirm ~/.claude/settings.json に追加(既存のhooksブロックに追記) CC AGENT TRACKER DEBUG=1 claude ← デバッグモードで起動 tail -f ~/.claude/logs/stop agent tracker.log 別ターミナルで監視 "Configured" and "working" are different things. Actually run a session and confirm the log appears before turning debug mode off. 10. Don't filter on subagent type alone; check for the existence of input.subagent type This is the counterpart to pitfall ④ the name="Task" problem . name == "Agent" alone may match entries other than subagent calls. Always include the second-stage filter "subagent type" in inp . That's how the real script is designed. 11. Use the cwd field to aggregate per project Both cost-log.jsonl and agent-invocations.jsonl contain cwd . To get time and cost per project, this jq is enough. プロジェクト別コスト jq -rsc ' group by .cwd | map {cwd: . 0 .cwd, cost usd: map .cost usd | add | . 10000 | round / 10000 } | sort by -.cost usd | "\ .cost usd USD \ .cwd " ' ~/.claude/logs/cost-log.jsonl Once you can see how much Claude Code you're using on which project, your sense of profitability changes. 12. Multiple Stop hooks can be registered and run independently hooks.Stop in ~/.claude/settings.json is an array and accepts multiple entries. That's why stop agent tracker.sh and stop cost log.sh are registered separately. If one fails, the other still runs. Splitting scripts by role lets you fix and swap them independently. 13. Build the habit of looking at the numbers weekly Accumulated logs mean nothing if you don't look at them. I run the ccstats alias every Monday morning. alias ccstats=' echo "=== エージェント平均時間(直近7日) ==="; jq -sc "map select .status==\"ok\" and .duration ms = null | group by .subagent type | map {type:. 0 .subagent type, avg ms: map .duration ms |add/length|round } | sort by -.avg ms " \ ~/.claude/logs/agent-invocations.jsonl; echo "=== 週間コスト ==="; jq -sc "map .cost usd | add | . 100 | round / 100" \ ~/.claude/logs/cost-log.jsonl ' After looking at these numbers, I can decide things like "cut back on code-reviewer calls this week" or "switch this project from Explore to Grep." Moving from gut feeling to number-based decisions is the real purpose of this system. Claude Code's Stop hook lets you automate log collection just by piggybacking on an existing event: session end. Receive transcript path , process it in two passes with Python, and append to agent-invocations.jsonl . The heart of this design is "change nothing in Claude Code itself." Even if the hook fails, it bails out with exit 0 , so nothing spills into the main behavior. Because the side effects are near zero, you can leave it in a high-volume environment with confidence. What the numbers actually showed me is that the agent I felt was heavy and the agent the data said was heavy weren't necessarily the same. In my environment, general-purpose felt heavy, but measured at an average of 18 seconds, it was faster than Explore 22 seconds . After seeing code-reviewer's 37 seconds, I developed the habit of "narrow down with Grep before reaching for code-reviewer on multi-file checks," and my weekly cost dropped by a felt 15–20%. That change would not have happened without measurement. A hook script quietly running at every session end keeps nudging up the precision of the autonomous environment behind ¥1.2M a month. Which of your subagents do you think is the slow one — and have you ever actually measured it? The full picture of the setup, the breakdown of the ¥1.2M/month, and a 30-day walkthrough are collected in a paid note Japanese . Written by Lily — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio https://bokuwalily.com · X https://x.com/bokuwalily · GitHub https://github.com/bokuwalily