← cd /blog

Article

Tracking What Claude Code Actually Does

·
buildstools

Ran Claude Code across a dozen projects for four months. Hundreds of sessions. Couldn't tell you how many tool calls any of them made, which files got touched most, or how long anything took. The context window closes and it's gone. The vault knows what I know. It doesn't know what Claude did.

Two hook scripts, a batch endpoint, and some JSONL files later, it does.

Update (March 14): The hooks described here have since been upgraded to v3. The buffer now captures milestones, edit diffs, and skill invocations. The flush posts session digests to a server-side API that auto-creates daily journal notes. The architecture is the same; the data got richer. See Compounding Two Weeks of Infrastructure for the latest.

The architecture

The telemetry system has three layers: a local buffer (bash, fast), a flush step (bash, runs once), and server-side storage (TypeScript, queryable).

PostToolUse hook          Stop hook               vaultctl server
─────────────────    ─────────────────────    ──────────────────────
Every tool call →    Session ends →           JSONL in vault →
append to /tmp/      aggregate buffer,        query by project,
claude-activity-     POST batch to            session, date range
{sessionId}.jsonl    localhost:3333

Every tool call gets logged locally. No HTTP, no latency. When the session ends, one batch POST ships everything to the server, which writes it to the vault as daily JSONL files in _meta/activity/. The buffer gets cleaned up. One request per session, not one per tool call.

The buffer hook

PostToolUse fires on every tool call. It has to be fast. No network, no processing, just append a line:

#!/bin/bash
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" \
  2>/dev/null || echo "unknown")
TOOL_NAME=$(echo "$INPUT" | python3 -c \
  "import sys,json; print(json.load(sys.stdin).get('tool_name','unknown'))" \
  2>/dev/null || echo "unknown")
TOOL_INPUT=$(echo "$INPUT" | python3 -c "
import sys,json
ti=json.load(sys.stdin).get('tool_input',{})
print(ti.get('file_path', ti.get('command', ti.get('pattern', '')))[:200])
" 2>/dev/null || echo "")

BUFFER="/tmp/claude-activity-${SESSION_ID}.jsonl"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

python3 -c "
import json, sys
print(json.dumps({'tool': sys.argv[1], 'target': sys.argv[2], 'ts': sys.argv[3]}))
" "$TOOL_NAME" "$TOOL_INPUT" "$TIMESTAMP" >> "$BUFFER"

The first version used bash string interpolation for the JSON line: echo "{\"tool\":\"${TOOL_NAME}\"}". Worked great until a tool input contained a double quote. Or a newline. Or a backslash. The JSONL file filled up with malformed lines and the flush script choked on every one of them. Replaced it with json.dumps() and moved on.

The flush hook

The Stop hook fires once when Claude finishes responding. It reads the buffer, aggregates tool counts, calculates duration, and ships everything in one batch:

# Aggregate with python3
SUMMARY=$(python3 << 'PYEOF'
import json, os
from collections import Counter

buffer_file = os.environ.get('BUFFER', '')
with open(buffer_file) as f:
    lines = [json.loads(line) for line in f if line.strip()]

tool_counts = Counter(l.get('tool', 'unknown') for l in lines)
files = list(set(l.get('target', '') for l in lines
    if l.get('target') and not l['target'].startswith(('ls ', 'git '))))

# Build batch: all tool:use events + one session:summary
batch = []
for line in lines:
    batch.append({
        "type": "tool:use",
        "sessionId": session_id,
        "cwd": cwd,
        "timestamp": line.get("ts"),
        "data": {"tool": line.get("tool"), "target": line.get("target")}
    })
batch.append({
    "type": "session:summary",
    "sessionId": session_id,
    "cwd": cwd,
    "data": summary
})
print(json.dumps({"events": batch}))
PYEOF
)

curl -s -X POST \
  -u admin:$AUTH_PASSWORD \
  -H "Content-Type: application/json" \
  -d "$SUMMARY" \
  --max-time 5 \
  "http://127.0.0.1:3333/api/v1/activity/batch" > /dev/null 2>&1

The first version didn't include auth credentials. The vaultctl server runs as a LaunchAgent with Basic Auth. The hook posted to a server that responded 401 every time. No error output (redirected to /dev/null). The buffer file got cleaned up regardless. Three days of sessions, zero data stored. Found it by tailing the server logs and seeing a wall of Unauthorized.

Server-side storage

Events land in _meta/activity/ inside the vault, one JSONL file per day:

const ACTIVITY_DIR = '_meta/activity';

function todayFile(vaultPath: string): string {
  return join(vaultPath, ACTIVITY_DIR, `${localToday()}.jsonl`);
}

export async function appendEvent(
  vaultPath: string, event: ActivityEvent
): Promise<void> {
  await ensureDir(vaultPath);
  await appendFile(todayFile(vaultPath), JSON.stringify(event) + '\n', 'utf-8');
}

One file per day means queries can skip irrelevant date ranges without reading them. Compaction deletes files older than N days. No database, no migrations, no binary formats. Just newline-delimited JSON that cat and jq can read.

Project resolution

The interesting problem: given a working directory like /Users/alfie/Projects/vaultctl, figure out which project that is. The answer lives in the vault itself. Project notes have a paths frontmatter field:

---
type: project
title: vaultctl
paths:
  - ~/Projects/vaultctl
  - ~/Projects/vaultctl/packages/desktop
---

The engine loads all project notes, expands tildes, and matches the session's cwd against them:

resolveProject(cwd: string): string | null {
  const mappings = this.getProjectMappings();
  const expanded = cwd.replace(/^~/, homedir());

  for (const mapping of mappings) {
    for (const pattern of mapping.patterns) {
      if (expanded.startsWith(pattern + '/') || expanded === pattern) {
        return mapping.project;
      }
    }
  }
  return null;
}

Eleven project notes, each with their filesystem paths. Sessions in ~/Projects/netsuite-suitelets resolve to "netsuite-suitelets". Sessions in ~/Projects/personal-site resolve to "testing-in-production-site". Sessions in an unknown directory resolve to null, which is fine. Not everything needs a label.

The bugs

The hookEventToActivityType method had a default case that mapped unknown hook events to session:start. Every PostToolUse, UserPromptSubmit, and Notification hook that passed through got logged as a new session starting. Hundreds of phantom sessions. Changed the default to tool:use.

Claude Code hook scripts receive input on stdin as JSON. The implementation plan assumed environment variables. The first version of the buffer hook tried to read $TOOL_NAME from the environment. It was always empty. Every line in the buffer said "tool": "unknown". Switched to python3 -c "json.load(sys.stdin)" and the data appeared.

The Write tool created hook scripts with CRLF line endings. Bash read "unknown\r" as the session ID. The buffer file was named claude-activity-unknown\r.jsonl. The flush script looked for claude-activity-${SESSION_ID}.jsonl, which resolved to a file with a carriage return in the name on one side and without it on the other. Nothing matched. Fixed with perl -pi -e 's/\r$//' on all three scripts.

Express 5 types req.params as string | string[] instead of string. Every req.params.sessionId needed an as string cast. The type error surfaced during the MCP build, which also needed NODE_OPTIONS="--max-old-space-size=8192" to avoid a heap OOM. Two unrelated problems discovered at the same time, which is the normal way to discover problems.

What it looks like

After a session, the Stop hook outputs a one-liner:

Session logged: 47 tool calls, 12.3 min, 8 files touched

The REST API exposes everything:

# Recent sessions
curl localhost:3333/api/v1/activity/sessions

# Project stats (last 30 days)
curl localhost:3333/api/v1/activity/projects/vaultctl/stats

# Raw events for a session
curl localhost:3333/api/v1/activity/sessions/abc123

Two MCP tools (session_activity and log_activity) let Claude query its own history. "What did I work on yesterday?" is now a question with an answer.

The process

Twelve tasks. Each one dispatched to a fresh subagent, implemented, spec-reviewed, code-quality-reviewed, committed. The subagent pattern works well for independent tasks: types, store, engine, API routes, hooks, MCP tools. Each subagent gets the full task spec and relevant code context, does its work, and hands back a commit. No context pollution between tasks.

Two code reviews caught three real issues: the session:start default, missing input validation on the POST endpoint, and the JSON injection in the buffer hook. The reviews paid for themselves.

496 tests now, up from 466. Thirty new tests across store operations and engine behaviour. The engine tests mock the knowledge index as a Map<string, KnowledgeNode>, which is worth mentioning because the implementation plan had the type wrong (array, not Map) and two subagents wasted time on it before I corrected the prompt.

The data

Sessions are accumulating. The vault knows which projects get the most attention, which tools get used most, and how long sessions actually run. It's not actionable yet. It will be, once there's enough of it.

Still running in production. The hooks fire on every session. The JSONL files grow.