Claude Code Mastery No. 008Editor's pick

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.

  • namehooks-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

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.

~/.claude/skills/hooks-automation/SKILL.md

--- name: hooks-automation description: Builds Claude Code hooks in settings.json that run shell commands on events such as PreToolUse, PostToolUse, UserPromptSubmit, Stop,… license: MIT ---

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?

RequirementUse
Must happen every time, no judgment (format, lint, log, block)Hook
Simple allow/deny of a tool or command prefixPermission 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 weighCLAUDE.md or a skill

Events

EventFiresMatcherCan block?stdout on exit 0
PreToolUseBefore a tool callTool name regexYes: exit 2, or JSON permissionDecisionTranscript only
PostToolUseAfter a tool call succeedsTool name regexNo (already ran); exit 2 sends stderr to Claude as feedbackTranscript only
UserPromptSubmitBefore Claude sees a prompt—Yes: exit 2 drops the promptAdded to context
StopMain agent finishes a response—Yes: exit 2 or {"decision":"block"} makes it continueTranscript only
SubagentStopA subagent finishes—Same as StopTranscript only
SessionStartStartup, resume, /clear, compactstartup, resume, clear, compactNoAdded to context
SessionEndSession ends—No—
NotificationClaude needs permission or is waiting for input—No—
PreCompactBefore compactionmanual, autoNo—

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" }
        ]
      }
    ]
  }
}
  • timeout is in seconds; the default is 60. All hooks matching an event run in parallel.
  • $CLAUDE_PROJECT_DIR is 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 codeEffect
0Success. stdout shown in transcript mode, or added to context for UserPromptSubmit/SessionStart
2Blocking error. stderr is fed to Claude (PreToolUse: tool call blocked; Stop: Claude keeps working; UserPromptSubmit: prompt rejected)
OtherNon-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_active and exit 0 when it is true, or Claude loops forever trying to satisfy the hook.
  • Quote every variable in shell; file paths from tool_input can 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 /hooks to review and apply, or restart the session.
  • jq is not installed everywhere. Use Python for JSON parsing in team hooks, or check for jq and fail with a clear message.
  • A Read deny rule does not stop cat .env through Bash; a Bash PreToolUse guard can.
  • Hooks run with your full user permissions. Before trusting a cloned repo, read its .claude/settings.json hooks; 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

  1. Standalone pipe tests pass for both allow and block inputs.
  2. /hooks lists the hook under the right event and matcher.
  3. In a live session, perform the triggering action and confirm the effect (file formatted, command blocked with the reason visible, notification shown).
  4. For problems, run claude --debug and read the hook execution lines (matched hooks, exit codes, stderr).
  5. 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 209 lines · 7.3 KB
# 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.

esc