Hooks Automation skill for Claude Code
Turn 'always do X' into behavior that never skips. Converts rules you keep repeating into hooks the harness runs every time. Claude picks the right event and matcher, writes a fast script that reads the hook JSON from stdin, tests the allow and block cases by piping sample payloads before registering anything, and explains each block so Claude can recover. Includes ready recipes for formatting, Bash guards, protected paths, test gates and notifications.
- name
hooks-automation - size185 lines · ≈2,100 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Claude Code hooks in settings.json that run shell commands on events such as PreToolUse, PostToolUse, UserPromptSubmit, Stop, SubagentStop, SessionStart, Notification, and PreCompact, to auto-format edits, block dangerous commands, protect files, run tests before Claude stops, inject context, and send notifications. Use when the user says "every time", "whenever", "before/after Claude edits", "never let it run X", asks for desktop alerts, or a hook is not firing. Try asking
- Run prettier on every file Claude edits, and ruff for Python files
- Never let Claude force push or rm -rf outside the repo
- Ping me with a desktop notification when Claude is waiting on me
Use it when
- A rule must apply every time, not just when Claude remembers
- You want formatting or linting right after each edit
- Certain commands or files must be blocked with a reason
- A configured hook is not firing or blocks everything
Skip it when
- A plain allow or deny by tool or command prefix is enough — use permissions-policy instead
- The instruction needs judgment rather than enforcement — use memory-file-architect instead
How to check the result
Pipe sample JSON for both an allowed and a blocked case into the script and check exit codes, then trigger the event in a live session and confirm it in /hooks and the transcript.
The common mistake it prevents
Exiting with code 1 to block a tool call; only exit code 2 or a JSON deny decision actually blocks, so the dangerous command runs anyway.
A hook is done when it fires on exactly the intended events, finishes in well under a second for tool events, fails safe, and has been tested by piping sample JSON into it before it was registered. Hooks are the only guaranteed behavior in Claude Code; memory and skills are advice.
Hook or something else?
| Requirement | Use |
|---|---|
| Must happen every time, no judgment (format, lint, log, block) | Hook |
| Simple allow/deny of a tool or command prefix | Permission rule in permissions (see permissions-policy) |
Deny that needs parsing (any rm -rf anywhere in a pipeline, writes outside src/) | PreToolUse hook |
| Guidance Claude should weigh | CLAUDE.md or a skill |
Events
| Event | Fires | Matcher | Can block? | stdout on exit 0 |
|---|---|---|---|---|
PreToolUse | Before a tool call | Tool name regex | Yes: exit 2, or JSON permissionDecision | Transcript only |
PostToolUse | After a tool call succeeds | Tool name regex | No (already ran); exit 2 sends stderr to Claude as feedback | Transcript only |
UserPromptSubmit | Before Claude sees a prompt | — | Yes: exit 2 drops the prompt | Added to context |
Stop | Main agent finishes a response | — | Yes: exit 2 or {"decision":"block"} makes it continue | Transcript only |
SubagentStop | A subagent finishes | — | Same as Stop | Transcript only |
SessionStart | Startup, resume, /clear, compact | startup, resume, clear, compact | No | Added to context |
SessionEnd | Session ends | — | No | — |
Notification | Claude needs permission or is waiting for input | — | No | — |
PreCompact | Before compaction | manual, auto | No | — |
Tool names for matchers: Bash, Edit, Write, MultiEdit, NotebookEdit, Read,
Glob, Grep, WebFetch, WebSearch, Task, and MCP tools as
mcp__<server>__<tool>. Matchers are case-sensitive regexes; "" or "*" matches all.
Configuration shape
Hooks live under "hooks" in a settings file. Scope by who should get them:
~/.claude/settings.json (you, everywhere), .claude/settings.json (team, committed),
.claude/settings.local.json (you, this repo).
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.sh", "timeout": 30 }
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard_bash.py" }
]
}
]
}
}
timeoutis in seconds; the default is 60. All hooks matching an event run in parallel.$CLAUDE_PROJECT_DIRis the project root. Always use it for script paths; the working directory changes during a session.- Keep scripts in
.claude/hooks/, committed and executable (chmod +x).
I/O contract
Input arrives as JSON on stdin. Common fields: session_id, transcript_path, cwd,
hook_event_name. Event-specific: tool_name, tool_input (PreToolUse/PostToolUse),
tool_response (PostToolUse), prompt (UserPromptSubmit), stop_hook_active
(Stop/SubagentStop), source (SessionStart), message (Notification).
| Exit code | Effect |
|---|---|
| 0 | Success. stdout shown in transcript mode, or added to context for UserPromptSubmit/SessionStart |
| 2 | Blocking error. stderr is fed to Claude (PreToolUse: tool call blocked; Stop: Claude keeps working; UserPromptSubmit: prompt rejected) |
| Other | Non-blocking error. stderr shown to the user; execution continues |
Structured output (exit 0 with JSON on stdout) gives finer control. PreToolUse:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to migrations/ applied in prod are not allowed; create a new migration."
}
}
permissionDecision can be allow (skip the prompt), deny (block, reason goes to
Claude), or ask (show the user a prompt). For Stop/SubagentStop use
{"decision": "block", "reason": "..."} to keep Claude working. If unsure whether a
field is supported in the installed version, fall back to exit codes, which are stable.
Workflow
- [ ] 1. State the rule as "on <event> for <tools>, do <action>, else <fallback>"
- [ ] 2. Pick event and matcher from the table
- [ ] 3. Write the script in .claude/hooks/, reading JSON from stdin
- [ ] 4. Test it standalone with piped sample JSON (allow case and block case)
- [ ] 5. Register it in the right settings scope
- [ ] 6. Trigger it in a live session and confirm in /hooks and the transcript
- [ ] 7. Commit script + settings together (team hooks) and document in CLAUDE.md
Step 4 is not optional. A hook with a syntax error either blocks every tool call or silently does nothing:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf / --no-preserve-root"}}' \
| python3 .claude/hooks/guard_bash.py; echo "exit=$?" # expect exit=2 and a reason on stderr
echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| python3 .claude/hooks/guard_bash.py; echo "exit=$?" # expect exit=0
For ready-made scripts (format-on-edit by file type, Bash guard, protected paths, run-tests-before-stop, desktop notifications, SessionStart context, prompt secret scanner, command audit log), read references/hook-recipes.md. Adapt them rather than writing from scratch.
Design rules
- Fast: PreToolUse and PostToolUse run on every matching call. Target under 500 ms. Format only the edited file, never the whole repo.
- Fail safe: decide what a crash means. A guard that crashes with exit 1 lets the command through; wrap parsing in try/except and exit 2 on malformed input only when blocking is the safe default.
- Explain blocks: the stderr text is what Claude reads to recover. Say what was blocked, why, and what to do instead.
- Narrow matchers:
Edit|Write|MultiEdit, not*, for file hooks. - Stop hooks must check
stop_hook_activeand exit 0 when it is true, or Claude loops forever trying to satisfy the hook. - Quote every variable in shell; file paths from
tool_inputcan contain spaces. - No network calls in tool hooks unless essential; they add latency and failure modes.
Gotchas
- Exit 1 does not block. Only exit 2 (or a JSON deny) blocks.
- PreToolUse stdout is not shown to Claude on exit 0; use stderr with exit 2 or the JSON reason field to communicate.
- Settings edits to hooks may not apply to a running session. Open
/hooksto review and apply, or restart the session. jqis not installed everywhere. Use Python for JSON parsing in team hooks, or check forjqand fail with a clear message.- A Read deny rule does not stop
cat .envthrough Bash; a Bash PreToolUse guard can. - Hooks run with your full user permissions. Before trusting a cloned repo, read its
.claude/settings.jsonhooks; never commit hooks that download and execute code. - PostToolUse formatters change files after Claude wrote them. That is fine, but do not also run a formatter in a Stop hook; double formatting with different configs causes churn.
Verification
- Standalone pipe tests pass for both allow and block inputs.
/hookslists the hook under the right event and matcher.- In a live session, perform the triggering action and confirm the effect (file formatted, command blocked with the reason visible, notification shown).
- For problems, run
claude --debugand read the hook execution lines (matched hooks, exit codes, stderr). - Time it:
time (echo '<sample json>' | <hook command>)stays under the target.
Output
**Hooks added** (.claude/settings.json, committed):
| Event | Matcher | Script | Behavior |
|---|---|---|---|
| PostToolUse | Edit\|Write\|MultiEdit | .claude/hooks/format.sh | prettier/ruff on the edited file only |
| PreToolUse | Bash | .claude/hooks/guard_bash.py | blocks rm -rf outside repo, force push, curl\|sh |
| Stop | — | .claude/hooks/require_tests.py | runs `pnpm test --run` once; blocks stop on failure |
**Tested:** pipe tests (6 cases), live trigger for each, all under 300 ms except Stop (8 s)
**Docs:** CLAUDE.md "Hooks" section lists what runs automatically Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/hook-recipes.md
# Hook recipes
Tested patterns to adapt. Each lists the settings entry and the script. Scripts read the
hook JSON from stdin. Put them in `.claude/hooks/` and `chmod +x` them.
Contents:
1. Format the edited file (PostToolUse)
2. Guard dangerous Bash commands (PreToolUse)
3. Protect paths from edits (PreToolUse)
4. Run tests before Claude stops (Stop)
5. Desktop notification when Claude is waiting (Notification)
6. Inject context at session start (SessionStart)
7. Block secrets pasted into prompts (UserPromptSubmit)
8. Audit log of shell commands (PreToolUse)
## 1. Format the edited file
```json
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format.py", "timeout": 30 }] }
```
```python
#!/usr/bin/env python3
import json, os, shutil, subprocess, sys
data = json.load(sys.stdin)
path = (data.get("tool_input") or {}).get("file_path", "")
if not path or not os.path.isfile(path):
sys.exit(0)
ext = os.path.splitext(path)[1].lower()
cmds = {
(".ts", ".tsx", ".js", ".jsx", ".json", ".css", ".md", ".yml", ".yaml"): ["npx", "--no-install", "prettier", "--write", path],
(".py",): ["ruff", "format", path],
(".go",): ["gofmt", "-w", path],
(".rs",): ["rustfmt", path],
}
for exts, cmd in cmds.items():
if ext in exts and shutil.which(cmd[0]):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
if r.returncode != 0:
print(f"formatter failed on {path}: {r.stderr.strip()[:500]}", file=sys.stderr)
sys.exit(2) # feed the error back to Claude so it fixes the syntax
sys.exit(0)
```
Use `--no-install` so a missing local prettier never triggers a download on every edit.
## 2. Guard dangerous Bash commands
```json
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard_bash.py" }] }
```
```python
#!/usr/bin/env python3
import json, re, sys
try:
cmd = json.load(sys.stdin)["tool_input"]["command"]
except Exception:
sys.exit(0) # not a Bash payload; do not block
RULES = [
(r"\brm\s+-[a-z]*r[a-z]*f?\s+(/|~|\$HOME)/?(\s|$)", "recursive delete of / or home"),
(r"\bgit\s+push\b.*(--force\b|-f\b)(?!-with-lease)", "force push; use --force-with-lease after asking the user"),
(r"\bgit\s+reset\s+--hard\b", "hard reset discards work; ask the user first"),
(r"(curl|wget)[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b", "piping a download into a shell"),
(r"\b(DROP\s+(TABLE|DATABASE)|TRUNCATE)\b", "destructive SQL; ask the user first"),
(r"\bchmod\s+-R\s+777\b", "world-writable permissions"),
(r"\b(cat|less|head|tail|cp|scp)\b[^|;&]*\.env(\.|\s|$)", "reading .env secrets through the shell"),
]
for pattern, why in RULES:
if re.search(pattern, cmd, re.IGNORECASE):
print(f"Blocked: {why}. Command: {cmd[:200]}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
```
Regex guards are a safety net, not a sandbox. Pair with permission deny rules and, for
untrusted work, the Bash sandbox.
## 3. Protect paths from edits
```json
{ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
"hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect_paths.py" }] }
```
```python
#!/usr/bin/env python3
import fnmatch, json, os, sys
PROTECTED = [".env", ".env.*", "*.pem", "package-lock.json", "pnpm-lock.yaml",
"migrations/applied/*", ".github/workflows/*", "generated/*"]
data = json.load(sys.stdin)
path = (data.get("tool_input") or {}).get("file_path") or (data.get("tool_input") or {}).get("notebook_path", "")
root = os.environ.get("CLAUDE_PROJECT_DIR", data.get("cwd", ""))
rel = os.path.relpath(path, root) if path else ""
if rel.startswith(".."):
print(f"Blocked: {path} is outside the project.", file=sys.stderr); sys.exit(2)
for pat in PROTECTED:
if fnmatch.fnmatch(rel, pat) or fnmatch.fnmatch(os.path.basename(rel), pat):
print(f"Blocked: {rel} is protected ({pat}). Explain the needed change to the user instead.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
```
## 4. Run tests before Claude stops
```json
"Stop": [{ "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/require_tests.py", "timeout": 300 }] }]
```
```python
#!/usr/bin/env python3
import json, subprocess, sys
data = json.load(sys.stdin)
if data.get("stop_hook_active"):
sys.exit(0) # already continued once because of this hook; never loop
# Only run when files changed in this repo
changed = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True).stdout.strip()
if not changed:
sys.exit(0)
r = subprocess.run(["npm", "test", "--silent"], capture_output=True, text=True, timeout=280)
if r.returncode != 0:
tail = "\n".join((r.stdout + r.stderr).strip().splitlines()[-40:])
print(f"Tests fail; fix them before finishing:\n{tail}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
```
Keep the test command fast (affected tests only) or this hook makes every turn slow.
## 5. Desktop notification when Claude is waiting
Put this in `~/.claude/settings.json` (personal, not team):
```json
"Notification": [{ "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/notify.py" }] }]
```
```python
#!/usr/bin/env python3
import json, platform, shutil, subprocess, sys
msg = json.load(sys.stdin).get("message", "Claude Code needs your attention")
if platform.system() == "Darwin":
safe = msg.replace('"', "'")
subprocess.run(["osascript", "-e", f'display notification "{safe}" with title "Claude Code"'])
elif shutil.which("notify-send"):
subprocess.run(["notify-send", "Claude Code", msg])
```
## 6. Inject context at session start
```json
"SessionStart": [{ "matcher": "startup|resume", "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/context.sh" }] }]
```
```sh
#!/bin/sh
# stdout becomes context. Keep it under ~30 lines.
echo "Branch: $(git branch --show-current 2>/dev/null)"
echo "Uncommitted files: $(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
[ -f .claude/handoff.md ] && { echo "--- handoff.md ---"; head -40 .claude/handoff.md; }
exit 0
```
## 7. Block secrets pasted into prompts
```json
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/prompt_secrets.py" }] }]
```
```python
#!/usr/bin/env python3
import json, re, sys
prompt = json.load(sys.stdin).get("prompt", "")
patterns = [r"sk-[A-Za-z0-9_-]{20,}", r"AKIA[0-9A-Z]{16}", r"gh[pousr]_[A-Za-z0-9]{30,}",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----"]
if any(re.search(p, prompt) for p in patterns):
print("Prompt blocked: it contains what looks like a credential. Remove it and rotate the key.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
```
## 8. Audit log of shell commands
```json
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/audit.py" }] }
```
```python
#!/usr/bin/env python3
import json, os, sys, time
d = json.load(sys.stdin)
line = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "session": d.get("session_id"),
"cwd": d.get("cwd"), "cmd": (d.get("tool_input") or {}).get("command")}
with open(os.path.expanduser("~/.claude/bash-audit.jsonl"), "a") as f:
f.write(json.dumps(line) + "\n")
sys.exit(0)
```
Rotate or cap the log file; it grows with every command.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.