# Claude Code PostToolUse hook: log every skill activation

> A PostToolUse hook on the Skill tool: appends a stub to receipts-drafts/. The config, the Node script, and the watcher quirk that took a restart.

**Canonical URL**: https://agentcookbooks.com/blog/claude-code-skill-activation-hook/

**Published**: 2026-04-29

**Tags**: claude-code, hooks, cookbook

---

Recipe #2 in the cookbook, after [the starter CWD guard](/blog/claude-code-hooks-cookbook/). 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](/skills/) 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`:

```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:

```js
#!/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:

```bash
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

1. **The settings watcher doesn't see directories that lacked a settings file at session start.** I created `.claude/settings.json` mid-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 `/hooks` once (reloads config) or restart Claude Code. After that, every skill activation auto-stubbed without further intervention.

2. **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.