Claude Code PostToolUse hook: log every skill activation
Recipe #2 in the cookbook, after the starter CWD guard. A PostToolUse hook on the Skill tool — every time a Claude Code skill activates, a timestamped [PENDING] stub gets appended to receipts-drafts/<slug>.md in the project root. The next assistant turn fills in qualitative observations: output gist, friction, observable numbers, ship/needs-more/kill verdict. Over weeks, the file becomes a corpus of real skill-usage notes that beats reconstructing receipts from memory. The post ships the .claude/settings.json block, the Node script (with the slug-sanitization regex), and the watcher quirk that took a session restart to figure out — plus the pipe-test that catches a broken script before you suspect the hook itself. Full cookbook ships once 12 hooks have run in production for 14 days each.
The hook: log every Skill activation
Goal: every time a Claude Code skill is invoked via the Skill tool, append a timestamped stub to a project-local markdown file. The stub captures the slug, the time, and a [PENDING] marker. On the next assistant turn, Claude fills in qualitative observations — output gist, friction, observable numbers, a ship/needs-more/kill judgment — into the same file. Over time the file accumulates a corpus of real skill-usage notes that beats reconstructing receipts from memory days later.
The file lives at receipts-drafts/<slug>.md in the project root, gitignored, one file per skill, dated entries appended downward. Don’t rotate — the corpus across many sessions is the value.
.claude/settings.json:
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PostToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/log-skill.mjs",
"timeout": 5
}
]
}
]
}
}
.claude/hooks/log-skill.mjs — reads the hook payload from stdin, sanitizes the skill slug, and appends a stub:
#!/usr/bin/env node
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
let raw = '';
process.stdin.on('data', (c) => (raw += c));
process.stdin.on('end', () => {
try {
const p = JSON.parse(raw);
if (p.tool_name !== 'Skill') return;
const rawSkill = p.tool_input?.skill;
if (!rawSkill || typeof rawSkill !== 'string') return;
// strip plugin namespace ("example-skills:foo" → "foo"),
// sanitize for filesystem
const slug = rawSkill.split(':').pop().replace(/[^a-zA-Z0-9._-]/g, '');
if (!slug) return;
const cwd = p.cwd || process.cwd();
const dir = join(cwd, 'receipts-drafts');
const file = join(dir, `${slug}.md`);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const ts = new Date().toISOString().slice(0, 16).replace('T', ' ');
if (!existsSync(file)) {
appendFileSync(file, `# receipts: ${slug}\n\n---\n`);
}
appendFileSync(
file,
`\n## ${ts} UTC — [PENDING] auto-stub\n\n` +
`**Trigger:** \`Skill\` tool, slug \`${slug}\`\n` +
`**Status:** awaiting follow-up — Claude fills in details next turn.\n\n---\n`,
);
} catch (err) {
// silent fail — never block the tool call
process.stderr.write(`[receipts-hook] ${err.message}\n`);
}
});
The script reads the JSON payload, returns early on anything that isn’t a Skill tool call, strips any plugin-namespace prefix, sanitizes the slug for filesystem safety, initializes the file with a header on first write, then appends a dated [PENDING] stub. Errors are caught and written to stderr — the tool call is never blocked by a misbehaving hook.
Pipe-test the script before relying on it:
echo '{"tool_name":"Skill","tool_input":{"skill":"smoke-test"},"cwd":"."}' \
| node .claude/hooks/log-skill.mjs
If receipts-drafts/smoke-test.md appears with a stub, the hook is correct. If it doesn’t, the script has a bug — fix before installing.
Two ways it broke
-
The settings watcher doesn’t see directories that lacked a settings file at session start. I created
.claude/settings.jsonmid-session for the first time, then invoked a skill expecting the hook to fire. No stub appeared. Pipe-test passed (synthetic JSON in, file written out), schema validated cleanly, the hook itself was correct. Claude Code’s harness just hadn’t picked up the new config — the watcher only walks directories that already had settings when the session started. Fix: open/hooksonce (reloads config) or restart Claude Code. After that, every skill activation auto-stubbed without further intervention. -
Silent stderr looks like “the hook didn’t run.” When the script throws a parse error early on, stderr captures it but no stub appears. I read the empty
receipts-drafts/as “the hook didn’t fire” and started chasing phantom config issues for ten minutes. Two changes that make this kind of failure visible: (a) the catch block prefixes errors with[receipts-hook]so they’re greppable in any stderr capture; (b) the pipe-test above is the cheap way to confirm the script side works before suspecting the hook side. If pipe-test passes and stubs still don’t appear in real sessions, it’s the watcher caveat from #1 — not the script.
Why two stages instead of one
The hook auto-stubs but doesn’t try to fill in the substance. That’s deliberate. Tool args can contain redaction-eligible content — sometimes user names, sometimes paths that contain identifying info, sometimes pasted snippets that shouldn’t be logged verbatim. The hook can’t redact intelligently; it can only dump or skip. So it dumps a minimal stub (timestamp + slug) and leaves the qualitative work — what was the goal, what did the skill produce, what broke, what’s the ship/needs-more/kill verdict — to the next assistant turn, where context-aware redaction is possible.
The split also makes the loop reliable. Hook = guaranteed presence of an entry. Claude = quality of the entry. If Claude forgets to fill in (network drops, session ends, fast-mode misses the cue), the stub is still there — visible the next time the file is reviewed.
What’s next
Ten more hooks lined up. Each one ships only after a real run on real code. Subscribe to RSS for the rest.