Claude Code Hooks — Research & Implementation Plan
For a fresh conversation. Eric was working on the Training Videos system + the
/technology-stackskill 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.
- Configured in
~/.claude/settings.json(global) or.claude/settings.json(per-project) - Each hook matches an event + (optionally) a tool name regex
- The shell command can:
- Echo text → Claude sees it as part of the tool result / context
- Return non-zero exit code → for events that support it, blocks the action
- Modify input/output via stdout JSON in some event types
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_inputvsinputvs 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)
- Git policy enforcement — PreToolUse on
Bash(git push origin main), block if no test run in this turn. Eric already has Mistakes-To-Avoid rules; a hook would make them mechanical. - Auto-format on save — PostToolUse on Edit/Write of
.tsx/.php, run Prettier/PHPCS, log diff if formatting changed. (Seeprettier-setupskill — likely overlap.) - 1Password reminder — UserPromptSubmit on prompts mentioning "password" / "credential" / "API key", inject "use op:// references, never commit secrets."
- Project-specific brief loader — SessionStart hook that detects CWD, finds the matching
~/.claude/project-notes/[name]/README.md, injects the status timeline once. Saves Claude from "let me check what we last did here" calls.
Open questions to settle in the new session
- 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. - Confirm hooks path — is it
~/.claude/settings.jsonor~/.claude-royal/settings.jsonfor 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. - Decide whether hooks are global, per-project, or both. The tech-stack drift hook arguably wants to be global (any repo with a
package.jsonbenefits). 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. - 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
~/.claude/settings.json(or~/.claude-royal/settings.json) — config~/.claude/hooks/tech-stack-drift-reminder.sh— proposed~/.claude/hooks/email-enforcement.sh— proposed/technology-stackskill —~/.claude-royal/skills/technology-stack/SKILL.md(already exists; references hooks at the bottom)/emailskill —~/.claude-royal/skills/email/(existing)/chattyskill —~/.claude-royal/skills/chatty/(existing)update-configskill —~/.claude-royal/skills/update-config/(existing — knows how to safely edit settings.json; use this skill when writing the actual hook config)
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
- Where this came from: the long session that built the Training Videos system + the
/technology-stackskill on 2026-04-28. Project hub:~/.claude-royal/project-notes/training-videos/. - The
/technology-stackskill mentions a hook recipe at the bottom of~/.claude-royal/skills/technology-stack/SKILL.md— the simpler "echo a reminder" version. The full plan above supersedes it. update-configskill is the right tool to actually editsettings.jsonsafely.