# How fast do your Claude Code agents run, and who is the session waiting on? Reads your local Claude Code logs, prints speed and time shares. Nothing leaves your machine.

> Source: <https://gist.github.com/paolo-perrone/febe1ced3ec28ecb6a9432938efd68b0>
> Published: 2026-11-11 20:16:21+00:00

|  | #!/usr/bin/env python3 | 
|  | """How fast do your Claude Code agents run, and who is the session waiting on? | 
|  | Reads your local Claude Code session logs (~/.claude/projects/*/*.jsonl) and prints: | 
|  | - how many responses ran at each speed setting (standard vs fast mode) | 
|  | - the median end-to-end output speed of your responses, in tokens per second | 
|  | - while the agent works on its own: the model's share of the time vs the tools' | 
|  | - across whole sessions: the model's, the tools' and your own share of the clock | 
|  | It reads only timestamps, token counts and the speed field. It never prints or | 
|  | sends message content. Nothing leaves your machine. | 
|  | Usage: | 
|  | python3 claude_code_speed.py # last 30 days, every project | 
|  | python3 claude_code_speed.py 7 # last 7 days | 
|  | python3 claude_code_speed.py 30 ~/.claude/projects/<one-project-dir> | 
|  | How the times are measured, so you can judge the numbers: | 
|  | - Model time: from the message the model answered (your prompt or a tool | 
|  | result) to the model's last chunk of that answer. It includes the wait for | 
|  | the first token, so the speed is end to end, like Artificial Analysis's. | 
|  | - Tool time: from the model's tool call to the tool's result. Waits over | 
|  | 5 minutes are left out: those are permission prompts or background jobs. | 
|  | - Your time: from the model's answer to your next message, each gap capped at | 
|  | 1 hour, so a night away from the keyboard does not count as thinking. | 
|  | - Speed is computed on responses of 200 output tokens or more, where the | 
|  | first-token wait matters least. Claude Code sends long contexts, so this | 
|  | end-to-end speed runs below the streaming speed a benchmark reports. | 
|  | - Output tokens include thinking tokens. | 
|  | - Resumed and forked sessions copy earlier history into a new log; each | 
|  | message is counted once. | 
|  | - Dollars are what the tokens would cost at Claude Opus 5.5 list prices, | 
|  | whatever model or plan you actually used. Fast mode doubles every price. | 
|  | """ | 
|  | import glob | 
|  | import json | 
|  | import os | 
|  | import sys | 
|  | import time | 
|  | from datetime import datetime | 
|  | TOOL_CAP = 300.0 | 
|  | # Claude Opus 5.5 list prices, dollars per million tokens, from | 
|  | # https://platform.claude.com/docs/en/about-claude/pricing (fetched 2026-11-10): | 
|  | # input, cache read, 5-minute cache write, 1-hour cache write, output. | 
|  | # Fast mode doubles every one of them (https://platform.claude.com/docs/en/build-with-claude/fast-mode). | 
|  | PRICE = (4.00, 0.20, 5.00, 8.00, 20.00) | 
|  | HUMAN_CAP = 3600.0 | 
|  | def ts(s): | 
|  | return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() | 
|  | SEEN = set() | 
|  | SEEN_REQ = set() | 
|  | def events(path): | 
|  | out = [] | 
|  | for line in open(path, errors="ignore"): | 
|  | try: | 
|  | d = json.loads(line) | 
|  | except ValueError: | 
|  | continue | 
|  | if d.get("isSidechain") or "timestamp" not in d: | 
|  | continue | 
|  | uid = d.get("uuid") | 
|  | if uid in SEEN: | 
|  | continue # a resumed or forked session repeats earlier history | 
|  | if uid: | 
|  | SEEN.add(uid) | 
|  | kind = d.get("type") | 
|  | msg = d.get("message") or {} | 
|  | if kind == "user": | 
|  | c = msg.get("content") | 
|  | if isinstance(c, list) and any(isinstance(x, dict) and x.get("type") == "tool_result" for x in c): | 
|  | who = "tool_result" | 
|  | elif d.get("isMeta") or (isinstance(c, str) and c.startswith("<")): | 
|  | who = "meta" | 
|  | else: | 
|  | who = "human" | 
|  | out.append((ts(d["timestamp"]), "user", who, 0, None)) | 
|  | elif kind == "assistant": | 
|  | u = msg.get("usage") or {} | 
|  | cc = u.get("cache_creation") or {} | 
|  | w1h = cc.get("ephemeral_1h_input_tokens") or 0 | 
|  | w5m = (u.get("cache_creation_input_tokens") or 0) - w1h | 
|  | use = (u.get("input_tokens") or 0, u.get("cache_read_input_tokens") or 0, w5m, w1h) | 
|  | ctx = sum(use) | 
|  | out.append((ts(d["timestamp"]), "assistant", d.get("requestId"), | 
|  | u.get("output_tokens") or 0, (u.get("speed"), ctx, use))) | 
|  | out.sort(key=lambda e: e[0]) | 
|  | return out | 
|  | def main(): | 
|  | days = int(sys.argv[1]) if len(sys.argv) > 1 else 30 | 
|  | root = os.path.expanduser(sys.argv[2]) if len(sys.argv) > 2 else os.path.expanduser("~/.claude/projects/*") | 
|  | cutoff = time.time() - days * 86400 | 
|  | files = sorted((f for f in glob.glob(os.path.join(root, "*.jsonl")) if os.path.getmtime(f) >= cutoff), | 
|  | key=os.path.getmtime) | 
|  | model = tool = human = 0.0 | 
|  | sessions = responses = 0 | 
|  | speed_setting, speeds, contexts, out_tokens = {}, [], [], 0 | 
|  | tokens_in = [0, 0, 0, 0] # uncached input, cache reads, 5-minute cache writes, 1-hour cache writes | 
|  | for f in files: | 
|  | ev = events(f) | 
|  | if not ev: | 
|  | continue | 
|  | sessions += 1 | 
|  | prev, i = None, 0 | 
|  | while i < len(ev): | 
|  | t, kind, key, _, _ = ev[i] | 
|  | if kind == "user": | 
|  | if prev is not None and t >= prev: | 
|  | gap = t - prev | 
|  | if key == "tool_result" and gap <= TOOL_CAP: | 
|  | tool += gap | 
|  | elif key == "human": | 
|  | human += min(gap, HUMAN_CAP) | 
|  | prev, i = t, i + 1 | 
|  | continue | 
|  | j, out, (setting, ctx, use) = i, 0, ev[i][4] | 
|  | while j < len(ev) and ev[j][1] == "assistant" and ev[j][2] == key: | 
|  | out = max(out, ev[j][3]) | 
|  | j += 1 | 
|  | end = ev[j - 1][0] | 
|  | first = key not in SEEN_REQ # one request can be logged in more than one piece | 
|  | SEEN_REQ.add(key) | 
|  | if prev is not None and 0 < end - prev < 1800: | 
|  | model += end - prev | 
|  | if first: | 
|  | responses += 1 | 
|  | contexts.append(ctx) | 
|  | if out >= 200: | 
|  | speeds.append(out / (end - prev)) | 
|  | if first: | 
|  | out_tokens += out | 
|  | for k in range(4): | 
|  | tokens_in[k] += use[k] | 
|  | if setting and first: | 
|  | speed_setting[setting] = speed_setting.get(setting, 0) + 1 | 
|  | prev, i = end, j | 
|  | if not responses: | 
|  | print("No sessions found. Check the path:", root) | 
|  | return | 
|  | speeds.sort() | 
|  | contexts.sort() | 
|  | q = lambda p: speeds[int(p * (len(speeds) - 1))] | 
|  | clock = model + tool + human | 
|  | print(f"Last {days} days: {sessions} sessions, {responses} model responses") | 
|  | print("Speed setting recorded:", ", ".join(f"{k} {v}" for k, v in sorted(speed_setting.items())) or "none") | 
|  | print(f"Median output speed: {q(0.5):.0f} tok/s (middle half {q(0.25):.0f} to {q(0.75):.0f}), " | 
|  | f"over {len(speeds)} responses of 200+ tokens") | 
|  | print(f"While the agent works: model {100 * model / (model + tool):.0f}% of the time, " | 
|  | f"tools {100 * tool / (model + tool):.0f}%") | 
|  | print(f"Across whole sessions: you {100 * human / clock:.0f}%, model {100 * model / clock:.0f}%, " | 
|  | f"tools {100 * tool / clock:.0f}%") | 
|  | print(f"Model time: {model / 3600:.0f} hours, {out_tokens / 1e6:.0f}M output tokens, " | 
|  | f"median context {contexts[len(contexts) // 2] / 1000:.0f}K tokens per response") | 
|  | cost = (sum(t * pr for t, pr in zip(tokens_in, PRICE[:4])) + out_tokens * PRICE[4]) / 1e6 | 
|  | print(f"At Claude Opus 5.5 list prices: ${cost:,.0f} on the standard tier " | 
|  | f"(${out_tokens * PRICE[4] / 1e6:,.0f} of it output), ${2 * cost:,.0f} on fast mode") | 
|  | m_share, a_share = model / clock, model / (model + tool) | 
|  | print(f"A model 2.5x faster would save about {100 * m_share * 0.6:.0f}% of your session clock, " | 
|  | f"and about {100 * a_share * 0.6:.0f}% of an agent's solo run time, " | 
|  | f"at most {model * 0.6 / 3600:.0f} hours of model time (fast mode speeds up streaming, " | 
|  | f"not the wait for the first token, so read these as ceilings)") | 
|  | if __name__ == "__main__": | 
|  | main() |
