My notes vault had no record of what Claude Code did: which tools, which files, how long. A PostToolUse hook appends one JSON line per tool call to a temp file. A Stop hook posts that file to a local server, which stores it as one JSONL file per day.
Claude wrote both scripts and the server. Below are the first versions, with the two fixes the flush needed applied. By 14 March they had become v3, covered in a later post, and on 11 August they were deleted.
Register both hooks in settings.json
Add both scripts to ~/.claude/settings.json (other hooks left out):
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/activity-buffer.sh" }]
}
],
"Stop": [
{
"hooks": [{ "type": "command", "command": "~/.claude/hooks/activity-flush.sh" }]
}
]
}
}
Put the scripts below in ~/.claude/hooks/ and chmod +x them. Each tool call costs one appended line and no network.
Build the buffer line with json.dumps()
PostToolUse fires on every tool call, so the hook only appends. Claude Code passes the hook its input as JSON on stdin. Reading $TOOL_NAME from the environment is the legacy interface. The first version's hook parsed stdin into shell variables with Python, then built the line in bash:
# WRONG: breaks on a quote, newline or backslash in the target
echo "{\"tool\":\"${TOOL_NAME}\",\"target\":\"${TOOL_INPUT}\",\"ts\":\"${TIMESTAMP}\"}" >> "$BUFFER"
A double quote in a command or a path ends the JSON string early. The flush parses every line with json.loads, so one bad line stops the whole batch. A fix before release moved the JSON into Python. The v3 hook reads stdin once and writes each line with json.dumps(). Its Python path:
#!/bin/bash
umask 077
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# ... a bash-only fast path for read-only tools
echo "$INPUT" | TIMESTAMP="$TIMESTAMP" python3 -c "
import sys, json, os
d = json.load(sys.stdin)
sid = d.get('session_id', 'unknown')
tool = d.get('tool_name', 'unknown')
ti = d.get('tool_input', {}) or {}
ts = os.environ.get('TIMESTAMP', '')
event = {'tool': tool, 'ts': ts}
if tool in ('Read', 'Write', 'Edit', 'Glob'):
fp = (ti.get('file_path', '') or ti.get('pattern', '') or '')
event['target'] = fp[:200]
# ... Write and Edit milestones, Bash commits, tests and deploys, skills, agents
else:
t = ti.get('file_path', ti.get('command', ti.get('pattern', ti.get('query', ti.get('url', '')))))
event['target'] = ((t or '')[:200]) or str(ti)[:100]
with open('/tmp/claude-activity-' + sid + '.jsonl', 'a') as f:
f.write(json.dumps(event) + chr(10))
" 2>>"/tmp/claude-activity-buffer-errors.log"
TIMESTAMP="$TIMESTAMP" on the same line as python3 puts the variable in the child's environment.
Pass the variables into the flush's Python
The flush reads the buffer, counts tools, works out a duration and posts one batch. The first version assigned BUFFER, SESSION_ID and CWD without export, then started the heredoc like this:
# WRONG: the Python child sees none of the three variables
SUMMARY=$(python3 << 'PYEOF'
The heredoc's Python reads all three from its environment, but a plain assignment does not reach a child process, so buffer_file comes back empty and the guard exits before a line of the buffer is read. SUMMARY stays empty, and the if skips the POST, the rm and the message. The hook exits 0, prints nothing and leaves the buffer in place.
Pass the variables on the python3 line, as v3 does. The flush with that fix and the credentials fix below, elided lines marked # ...:
#!/bin/bash
# Stop hook: aggregate buffer and flush to vaultctl
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")
CWD=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
BUFFER="/tmp/claude-activity-${SESSION_ID}.jsonl"
if [ ! -f "$BUFFER" ]; then
# No activity recorded, just signal session end
exit 0
fi
# Aggregate with python3 (available on macOS)
# FIX 1: pass the variables on the python3 line, or the child never sees them
SUMMARY=$(BUFFER="$BUFFER" SESSION_ID="$SESSION_ID" CWD="$CWD" python3 << 'PYEOF'
import json, sys, os
from collections import Counter
from datetime import datetime
buffer_file = os.environ.get('BUFFER', '')
session_id = os.environ.get('SESSION_ID', 'unknown')
cwd = os.environ.get('CWD', '')
if not buffer_file or not os.path.exists(buffer_file):
sys.exit(0)
with open(buffer_file) as f:
lines = [json.loads(line) for line in f if line.strip()]
if not lines:
sys.exit(0)
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 ', 'npm '))))
started = lines[0].get('ts', '')
ended = lines[-1].get('ts', '')
# ... duration in minutes from started to ended, then the summary dict
# Build batch payload: all tool:use events + the summary
batch = []
# ... one tool:use event per buffered line, then the summary
print(json.dumps({"events": batch}))
PYEOF
)
if [ -n "$SUMMARY" ]; then
# Flush to vaultctl (non-blocking, 5s timeout)
# FIX 2: send credentials, or the server answers 401
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
# Clean up buffer
rm -f "$BUFFER"
# Output session stats as a system message for the user
# ... TOOL_COUNT, DURATION and FILE_COUNT read back from $SUMMARY
# Return JSON output with system message
cat << EOF
{"systemMessage": "Session logged: ${TOOL_COUNT} tool calls, ${DURATION} min, ${FILE_COUNT} files touched"}
EOF
fi
Stop fires after every response, not at session end
Stop fires each time Claude finishes a response. SessionEnd fires when the session terminates. A flush on Stop therefore posts once per response that called a tool, then deletes the buffer. Duration runs from the first to the last buffered call, so it covers one response. The "Session logged" message is wrong: each one covers a single response.
Send the credentials, because curl throws the 401 away
The vaultctl server runs as a LaunchAgent with Basic Auth. The first version's curl had no -u. Without credentials the server answers 401 Authentication required. Once 20 attempts have failed within 15 minutes, it answers 429 instead. It logs neither. The curl sends its output to /dev/null, and the rm -f after it runs whatever the answer was. The buffer is deleted and nothing records the failure.
Add -u admin:$AUTH_PASSWORD to the curl. The server's user defaults to admin. v3 reads the password from the macOS Keychain with security find-generic-password. Run a hook's curl once by hand, without the redirect, before trusting it.
Store one JSONL file per day
The server appends each event to a dated file under _meta/activity/ in the vault:
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');
}
A query opens only the files whose date falls inside its range. POST /api/v1/activity/compact deletes whole files older than a cutoff, 90 days by default. cat and jq read everything.
The server's hookEventToActivityType first mapped every unknown hook event to session:start. A PostToolUse, UserPromptSubmit or Notification payload sent raw to the server would have been logged as a new session. Unknown events became tool:use before release.
Map the working directory with a paths field
Project notes list the folders they own in their frontmatter. An example:
# 01_Projects/vaultctl.md
type: project
status: active
paths:
- ~/Projects/vaultctl
- /tmp/claude-worktrees/vaultctl-*
The engine expands ~ and returns the first project whose path equals the session's cwd or contains it:
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;
}
// ... glob patterns, matched with fast-glob
}
}
return null;
}
A parent path already matches every folder below it, so list only the top folder. The glob catches worktrees under /tmp/claude-worktrees/. A session in an unmapped folder gets null.
Note: The first match wins and nothing warns about overlaps. Two of my project notes list
~/Projects/personal-sitetoday. A session there gets whichever one the index yields first.
Query it over REST or MCP
# 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
sessions returns the last 7 days, 20 at most. Each flush writes one session:summary, so those 20 are responses, not sessions. stats takes ?days= and defaults to 30. Two MCP tools sit on top: session_activity queries the history and log_activity records a decision or pattern. With auth on, each curl above needs -u admin:$AUTH_PASSWORD too.