DEV Community

J. Gravelle
J. Gravelle

Posted on

What's actually filling your Claude Code session? Count the tool results.

If you're hitting your Claude Code limit faster than seems reasonable, find out which tool put the most text into the session before you change anything. The session log is on your own disk, a short script will rank it for you, and the answer is often not the thing you were about to blame.

Measure first. Then you don't have to take my word for it, or anybody else's. I wrote jCodeMunch, so I admittedly have a (very big, but also very popular) dog in this hunt.

Why tool results are the thing to count

Claude Code sends your full conversation with every request, and each time the model uses tools it sends another request carrying that batch of tool results. That's from Anthropic's own cost docs, not from me. So a big file read lands in the session once and then rides along on every request after it, until you /clear or the session compacts. Caching makes the re-reads cheaper. It doesn't make them free, and on a subscription they still count toward your window.

The built-in views

Run these first. They cost nothing.

/context shows what is occupying the context window right now. /usage, on a Pro or Max plan, breaks recent usage down by skills, subagents, plugins and individual MCP servers, and it flags things like long context and cache misses when one of them passes 10% of recent usage.

What neither one tells you is which tool calls, and which files, put the text there. That's in the log.

The log

Claude Code keeps each session as a JSONL file under ~/.claude/projects/. A tool call is a tool_use block with an id and a name. Its result comes back in a later line as a tool_result block carrying the same id. Join the two, count the tokens in each result, group by tool.

# toolcost.py
import collections, glob, json, os, sys

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
path = sys.argv[1] if len(sys.argv) > 1 else max(
    glob.glob(os.path.expanduser("~/.claude/projects/*/*.jsonl")), key=os.path.getmtime)

calls = {}                                # tool_use id -> (tool name, target)
cost = collections.Counter()              # tool name -> tokens returned
count = collections.Counter()
reads = collections.Counter()             # file path -> tokens returned by Read

for line in open(path, encoding="utf-8"):
    try:
        msg = json.loads(line).get("message")
    except ValueError:
        continue
    if not isinstance(msg, dict) or not isinstance(msg.get("content"), list):
        continue
    for block in msg["content"]:
        if not isinstance(block, dict):
            continue
        if block.get("type") == "tool_use":
            target = block.get("input", {}).get("file_path", "")
            calls[block["id"]] = (block["name"], target)
        elif block.get("type") == "tool_result":
            body = block.get("content")
            if isinstance(body, list):
                body = "".join(p.get("text", "") for p in body if isinstance(p, dict))
            n = len(enc.encode(body or "", disallowed_special=()))
            name, target = calls.get(block.get("tool_use_id"), ("?", ""))
            cost[name] += n
            count[name] += 1
            if name == "Read":
                reads[target] += n

print(path)
total = sum(cost.values()) or 1
for name, n in cost.most_common(12):
    print(f"{n:>10,}  {100 * n / total:5.1f}%  {count[name]:>4} calls  {name}")
print("\nLargest file reads:")
for target, n in reads.most_common(8):
    print(f"{n:>10,}  {target}")
Enter fullscreen mode Exit fullscreen mode
pip install tiktoken
python toolcost.py                        # your most recent session
python toolcost.py path/to/session.jsonl  # or a specific one
Enter fullscreen mode Exit fullscreen mode

It reads one local file and prints a table. Nothing leaves your machine, which matters, because those logs hold your prompts and your source.

Two honest limits. cl100k_base is not Claude's tokenizer, so use the output to rank things and don't treat it as a bill. And each result is counted once, at the size it arrived. It doesn't model how many later requests carried it, so a big read early in a long session cost you more than a big read at the end.

The log format isn't documented and it moves between versions. If the script prints an empty table, open the file and look at a few lines (it worked on mine today, 2026-10-12).

Reading the table

Bash on top? That's usually test output or build logs. Filter it before the model sees it (Anthropic's cost page has a hook that keeps only the failing lines) or hand the noisy job to a subagent, so only the summary comes back.

An MCP tool on top means that server answers with walls of text. See whether it can be asked for less. If it can't, decide whether you need it connected all day.

Read on top, with source files listed underneath, means the agent is opening whole files to look at parts of them. The rest of this is about that case.

What a whole file costs next to the part you wanted

Here's one you can check with stock Python. In CPython 3.12.4, json/decoder.py is 3,024 tokens (tiktoken cl100k_base). The one function py_scanstring inside it is 557.

import ast, json.decoder, tiktoken

enc = tiktoken.get_encoding("cl100k_base")
src = open(json.decoder.__file__, encoding="utf-8").read()
fn = next(n for n in ast.parse(src).body if getattr(n, "name", "") == "py_scanstring")
print(len(enc.encode(src)), len(enc.encode(ast.get_source_segment(src, fn))))
Enter fullscreen mode Exit fullscreen mode

So an agent that needed that function and read the file took in more than five times what it needed. That's a 356-line file. The gap grows with file size, and on small files it doesn't exist.

Fixes, cheapest first

Ask narrower questions. "Improve this codebase" makes any agent scan broadly. Naming the file and the function costs you ten seconds of typing.

/clear when you switch to unrelated work. Everything still in the session is being re-sent.

If you work in a typed language, install a code intelligence plugin for it. Anthropic's docs say a single go-to-definition call replaces a grep followed by reading several candidate files. It's first-party and it's free.

Or use a symbol index, which is what jCodeMunch is. It indexes the repo once and hands the agent one function or class by byte offset, where it would otherwise have read the file. Setup is two commands, and it runs with no API key:

pip install jcodemunch-mcp
jcodemunch-mcp init
Enter fullscreen mode Exit fullscreen mode

My published benchmark is a 95% average reduction in code-reading tokens, measured with tiktoken cl100k_base over 15 tasks on 3 real repos. The 99.8% on my homepage is one query against fastapi/fastapi: 214,312 tokens to read the whole source tree, about 480 through the index. No agent reads the whole tree, so treat that one as a ceiling.

Whatever you pick, run toolcost.py again on a session doing the same kind of work. An index's own results show up in the table too (mine as mcp__jcodemunch__ rows), and they count against it. If the Read row didn't drop by more than those rows added, it isn't helping you. Take it out.

Short answers

Do fewer tokens mean a smaller bill? On an API key, input spend goes down roughly in step. On Pro or Max you pay the same either way. What changes is how long you work before the limit shows up.

Is tiktoken close enough for Claude? For ranking one tool against another inside a session, yes. For an exact count, no.

Does this work for Cursor or Codex? The script reads Claude Code's logs only. The method carries over to any agent that logs its tool results. I haven't checked their formats.

What if my files are all small? Then file reads probably aren't your problem and an index won't do much for you. Look at the other rows.

If your table shows something I didn't cover here, I'm eager to see it...

Top comments (0)