Claude Code Hooks — Research & Implementation Plan

For a fresh conversation. Eric was working on the Training Videos system + the /technology-stack skill in a long session that's getting expensive to keep loaded. This doc captures everything we know about Claude Code hooks plus the two concrete use cases on the table, so the next session can pick up cleanly.

How to bootstrap a new session

In a clean Claude Code conversation, paste this exact prompt:

Read ~/.claude-royal/project-notes/hooks-research/README.md — that's where
we left off on hooks research. Verify the hook event schemas against
current Claude Code docs (my knowledge is from training-data, may be
slightly stale). Then walk me through the next decision: ship the
gentle PostToolUse reminder for tech-stack drift first, or ship the
hard UserPromptSubmit injection for the email skill first?

That's it. The new conversation reads this file → verifies schemas → asks the next question. No paste of code, no re-explaining.


What hooks are (~60 second version)

Hooks are shell commands the Claude Code harness runs at lifecycle events. They run outside Claude's decision loop — meaning Claude can't ignore them, forget them, or talk its way around them.

The harness handles the orchestration; Claude only sees the result of the hook.

Hooks vs skills — keep this straight

Skills Hooks
Triggered by Claude's interpretation of intent (or /explicit invoke) A harness lifecycle event — always fires
Decision Fuzzy / semantic (Claude reads the description and decides) Deterministic (fires every time the event happens)
Filtering Inside the skill's "when to use" prose; Claude decides Inside the script — grep, regex, file-path checks, etc.
Can Claude skip? Yes (Claude's call) No (runs outside Claude's loop)
Cost Token overhead per skill in registry Near-zero per fire (sub-50ms early-exit script)

Common confusion: "hooks only fire on keywords." Wrong. A UserPromptSubmit hook fires on EVERY prompt. The script inside it checks keywords and decides whether to inject anything. Mechanically it's always running — usually doing nothing.

Skills + hooks compose. The hook gets you to the skill (forces Claude past the "I'll just wing it" failure mode). The skill does the work. Neither replaces the other.

Concrete flow for "draft an email to John":

1. UserPromptSubmit hook fires (always)
2. Script greps for email keywords → MATCH
3. Script echoes "[SYSTEM: invoke /email FIRST]"
4. Claude sees prompt + injected directive
5. Claude invokes /email skill
6. /email skill does the email work properly

False-positive risk: "look up the email column in the database" would match the same regex. Tune triggers conservatively or accept some injection-noise on adjacent topics.


Event reference

Event Fires Can block? Common use
UserPromptSubmit User hits Enter on a prompt Yes (block or rewrite) Inject context, enforce skill invocation, gate prompts on conditions
PreToolUse Before any tool runs Yes (block the tool) "Don't push to main without confirming"; enforce git policy
PostToolUse After a tool finishes No Surface reminders, log changes, run linters
SessionStart New conversation begins No Inject one-time project context
Stop Claude is about to stop responding Yes (force keep-going) "Run tests before stopping"
Notification Claude prompts user (input request, etc.) No Pipe to ntfy/Slack/etc.
SubagentStop A spawned sub-agent finishes Yes Gate sub-agent return
PreCompact Before context compaction No Save state, write session summary

⚠️ Verify before implementing: the schema and event names above are from training-data memory. Confirm against the live Claude Code docs in the new session — Anthropic has been iterating on this. The general shape is right; specific field names may have shifted.


Scoping: global vs per-project

Hooks live in one of two places:

Scope Path Fires when
Global ~/.claude/settings.json (or ~/.claude-royal/settings.json for that account) Every project Claude Code is invoked from
Per-project .claude/settings.json inside the repo Only when CWD is inside that project

Critical nuance. The matcher field is a regex on tool names (Edit, Write, Bash, etc.), NOT on file paths. So a global PostToolUse with matcher: "Edit|Write" runs the script on every Edit/Write everywhere. The script itself decides whether to do anything based on the file path passed via stdin. A 5-line guard that exits early if the path doesn't match adds <50ms — negligible.

The opt-in pattern (recommended for both use cases):

Register the hook globally + write a self-gating script. Example for tech-stack drift:

#!/bin/bash
INPUT=$(cat)
FILEPATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

# Bail out if this project hasn't opted in
PROJECT_ROOT=$(git -C "$(dirname "$FILEPATH")" rev-parse --show-toplevel 2>/dev/null)
if [ -z "$PROJECT_ROOT" ] || [ ! -f "$PROJECT_ROOT/docs/TECHNOLOGY-STACK.md" ]; then
  exit 0  # no tech-stack doc = no-op
fi

case "$FILEPATH" in
  *package.json|*composer.json|*prisma/schema.prisma|*requirements.txt)
    echo "🔔 Stack-relevant file changed in $PROJECT_ROOT"
    echo "→ Run /technology-stack update before committing."
    ;;
esac
exit 0

Result: globally registered, but only fires for real on projects that have opted in by creating docs/TECHNOLOGY-STACK.md via /technology-stack init. Add a project = create the doc. Remove = delete the doc. No settings.json juggling.

When per-project IS the right call: - The rule is specific to a repo's deploy model (e.g., "block force-push to main") - Different policies in client vs internal repos - The hook needs paths/secrets specific to that project

Decisions for our two use cases:

Use case Scope Reason
Tech-stack drift Global + self-gating script One setup, scales to every project that has a tech-stack doc
Email enforcement Global, no gating You want this enforced everywhere, no exceptions

Configuration shape (approximate)

~/.claude/settings.json:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "/path/to/script.sh"
      }
    ],
    "UserPromptSubmit": [
      {
        "command": "/path/to/another-script.sh"
      }
    ]
  }
}

The matcher field accepts a regex on tool names (or * for any). Path-based matching (specific files like package.json) typically happens inside the hook script by inspecting the tool input, which is passed as JSON via stdin.

The script reads stdin (JSON envelope describing the tool call), does its thing, writes to stdout (Claude sees it), exits 0 (allow) or non-zero (block, where applicable).

Verify the exact stdin/stdout JSON format in current docs. Past versions have differed on whether tool input arrives as tool_input vs input vs flattened keys.


Use case A — Tech-stack drift reminder (PostToolUse, gentle)

Goal. When Claude edits package.json, composer.json, prisma/schema.prisma, etc., remind future-Claude (in the same session) to run /technology-stack update before committing.

Why this works. PostToolUse fires after the change. Claude's next turn includes the hook output as context. The reminder is in front of Claude exactly when relevant — no force, just nudge. Catches doc rot at the source.

Approximate config:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "command": "~/.claude/hooks/tech-stack-drift-reminder.sh"
      }
    ]
  }
}

Approximate script (~/.claude/hooks/tech-stack-drift-reminder.sh):

#!/bin/bash
# Read tool-call JSON from stdin
INPUT=$(cat)
FILEPATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')

case "$FILEPATH" in
  *package.json|*composer.json|*requirements.txt|*pyproject.toml|*prisma/schema.prisma|*Gemfile)
    echo "🔔 Dependency / schema change detected at $FILEPATH"
    echo "→ Run /technology-stack update before committing this so the doc stays current."
    ;;
esac

exit 0

Risk profile. Low. It just prints text. If the trigger is wrong it produces noise; if it's right it produces value. Nothing dangerous.

Implementation order. Ship this first. Smaller blast radius, easier to validate the hook plumbing works before doing the harder UserPromptSubmit one.


Use case B — Email skill enforcement (UserPromptSubmit, hard injection)

Goal. Claude has been "forgetting to read [Eric's] requirements" when handed email tasks. The fix isn't memory or instructions — those rely on Claude noticing. The fix is mechanical injection: when the user's prompt mentions email-related content, the hook prepends a system note that Claude cannot skip because it's literally part of what Claude receives.

Trigger words/patterns to detect: - email, draft, reply, inbox - mail.google.com/... URL - "send a message to [person]" patterns (harder; might need looser regex) - Specific recipients in Eric's contact list (could read ~/.claude-royal/people/)

Approximate config:

{
  "hooks": {
    "UserPromptSubmit": [
      {
        "command": "~/.claude/hooks/email-enforcement.sh"
      }
    ]
  }
}

Approximate script (~/.claude/hooks/email-enforcement.sh):

#!/bin/bash
# Read prompt JSON from stdin (verify schema)
INPUT=$(cat)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')

# Check for email-related keywords
if echo "$PROMPT" | grep -iE 'email|draft|reply|inbox|mail\.google\.com' >/dev/null; then
  cat <<'EOF'
[SYSTEM ENFORCEMENT — do not skip]

This message involves email. BEFORE doing anything else:

1. Invoke the /email skill — it is the single entry point for all Gmail work
2. Read the /chatty skill — it is the canonical voice/tone profile
3. If the recipient or intent is unclear, use AskUserQuestion (NOT a list of
   questions in your response — Eric's hard rule)

Failure to invoke /email first has been a recurring complaint. Don't skip.
EOF
fi

# Pass the original prompt through (verify whether you echo it or use a different mechanism)
echo "$PROMPT"
exit 0

Risk profile. Higher than A. The script touches every prompt the user submits, so: - A bug here can break Claude's ability to respond at all - Over-broad triggers (e.g., matching "email" in a prompt about a database email column) inject noise into unrelated work - Test the trigger regex carefully against false positives

Recommendation: before installing, run a few sample prompts through the script manually to see what it injects. Tune triggers conservatively — better to miss a few real cases than to inject on every other prompt.

Stronger variant (PreToolUse gate, optional): match Edit/Write tool calls in a turn that started with an email-related prompt; block the tool if Claude didn't invoke /email first. More aggressive, more annoying to debug, but ironclad. Only if the soft injection isn't enough.


Other use cases worth considering (parking lot)


Open questions to settle in the new session

  1. Verify event names + JSON schemas against the current Claude Code docs. My summary above is best-effort from training-data memory — Anthropic ships changes here often. Use WebFetch on https://docs.claude.com/en/docs/claude-code/hooks (or wherever it lives now) to confirm.
  2. Confirm hooks path — is it ~/.claude/settings.json or ~/.claude-royal/settings.json for Eric's setup? He runs multi-account (claude + claude-royal). Each has its own settings dir. Hooks may need to live in BOTH for full coverage.
  3. Decide whether hooks are global, per-project, or both. The tech-stack drift hook arguably wants to be global (any repo with a package.json benefits). The email hook is global too (always relevant). But a "PreToolUse gate before push to main" might want to be per-project. The new conversation should think through this layering.
  4. Test in shadow mode first. Both hooks should print their decision to stderr so we can SEE what they would have done before they actually do it. Run for a day with the print-only mode, validate the triggers, then switch to active.

Files / paths involved


Status as of handoff

Item State
Eric understands the hook concept
Concrete use cases identified ✓ (tech-stack drift + email enforcement)
Schema verified against current docs ❌ pending
First implementation chosen ❌ pending — recommend tech-stack drift first
Settings.json updated
Hook scripts written
Tested in shadow mode
Active

The new session should pick up at "schema verified against current docs", then proceed in order.

Cross-references