Building a Complete Hook System
- Design a three-layer hook architecture covering security gates, feedback loops, and completion validation
- Write reusable helper scripts that standardize hook input parsing and output formatting across a project
- Choose a distribution strategy based on reach requirements: project settings, plugin, or managed settings
From Individual Hooks to a System
Individual hooks solve individual problems. A security hook blocks destructive commands. A logging hook records tool usage. A test hook validates turn results. Each is useful in isolation, but the real power emerges when hooks compose into a system — a coordinated set of handlers that enforces standards at every stage of the Claude Code lifecycle.
A well-designed hook system has layers. Each layer addresses a different phase of a tool call's lifecycle, and the layers reinforce each other. A security hole in the permission layer can be caught by the completion gate. A linting miss in the feedback layer is visible in the Stop output. The system is more robust than any individual hook because it has multiple chances to enforce standards.
This lesson walks through designing and building a three-layer hook system for a real project, from the initial architecture to the final distribution strategy.
Layer One: The Security Gate
The security gate is built on PreToolUse. It fires before every tool call and its job is to prevent dangerous actions before they happen. No tool call that violates project policy should reach the execution phase.
A complete security gate for a web application project blocks three categories of risk: destructive filesystem operations, command injection vectors, and unauthorized network calls.
#!/bin/bash
# .claude/hooks/security-gate.sh
source "$(dirname "$0")/lib.sh"
input=$(parse_stdin)
tool=$(get_field "$input" .tool_name)
cmd=$(get_field "$input" .tool_input.command)
file=$(get_field "$input" .tool_input.file_path)
# Block destructive rm
if [ "$tool" = "Bash" ] && echo "$cmd" | grep -qE "rms+-[a-z]*r[a-z]*f"; then
deny "Recursive force removal blocked. Use: mv path /tmp/trash/ instead."
fi
# Block curl piped to shell
if [ "$tool" = "Bash" ] && echo "$cmd" | grep -qP "curl.*|s*(bash|sh|zsh)"; then
deny "curl piped to shell is not permitted. Download and inspect scripts before running."
fi
# Block force push
if [ "$tool" = "Bash" ] && echo "$cmd" | grep -qE "git push.*--force|git push.*-f"; then
deny "Force push is disabled. Use --force-with-lease or open a PR."
fi
# Block writes to system directories
if [ "$tool" = "Write" ] || [ "$tool" = "Edit" ]; then
if echo "$file" | grep -qE "^/(etc|usr|bin|sbin|system)"; then
deny "Writing to system directories is not permitted."
fi
fi
allow_with_log "$tool" "$cmd"
Note the calls to deny and allow_with_log — these are functions from a shared lib.sh that standardize the output format across all hooks in the project.
Layer Two: The Feedback Layer
The feedback layer is built on PostToolUse. It fires after tool calls succeed and its job is to catch problems that slipped through the security gate and to maintain code quality standards.
Three feedback hooks work together in a typical project:
The linter hook fires on Edit and Write calls and runs the project linter on the modified file. If the linter finds issues, it returns them as a systemMessage that Claude sees in the next turn and can act on immediately.
The audit hook fires on all Bash calls and appends the command, its output, and the timestamp to .claude/audit.log. This creates a complete record of every shell command executed during the session — useful for security reviews and for understanding what happened when something goes wrong.
The secret scanner fires on Edit and Write calls and checks the file content for patterns matching API keys, tokens, and credentials. If it finds a match, it emits a warning systemMessage before the file reaches git.
These three hooks are all async: true for the audit hook (which never needs to block) and synchronous for the linter and scanner (which need to provide feedback before the next tool call).
Layer Three: The Completion Gate
The completion gate is built on Stop. It fires when Claude finishes a response and its job is to validate the outcome of the turn before it closes. The gate does not check what Claude did — it checks whether the result is acceptable.
For most projects, the completion gate runs the test suite. If tests pass, the gate injects the pass count as additionalContext and the turn closes. If tests fail, the gate exits 2 with the failure summary, keeping the turn open so Claude can fix the failures before proceeding.
#!/bin/bash # .claude/hooks/completion-gate.sh source "$(dirname "$0")/lib.sh" # Only run if source files were changed this turn if ! files_changed_this_turn "*.ts" "*.js"; then exit 0 fi result=$(npm test 2>&1) if [ $? -ne 0 ]; then failed=$(echo "$result" | grep -oP "d+ failed" | head -1) echo "Tests failing: $failed. Review the output and fix before completing." >&2 exit 2 fi passed=$(echo "$result" | grep -oP "d+ passed" | head -1) cat <
Helper Scripts and Reusable Patterns
Without shared utilities, every hook duplicates the same JSON parsing, output formatting, and logging boilerplate. A lib.sh file shared across all project hooks eliminates this duplication and ensures consistent behavior.
A minimal lib.sh for a project hook system:
#!/bin/bash
# .claude/hooks/lib.sh
AUDIT_LOG="$(git rev-parse --show-toplevel 2>/dev/null)/.claude/audit.log"
parse_stdin() { cat; }
get_field() { echo "$1" | jq -r "$2 // empty"; }
deny() {
local reason="$1"
cat <> "$AUDIT_LOG"
exit 0
}
files_changed_this_turn() {
local transcript="$(cat /dev/stdin | jq -r .transcript_path)"
local patterns=("$@")
for p in "${patterns[@]}"; do
if grep -q ""file_path":.*$p" "$transcript" 2>/dev/null; then
return 0
fi
done
return 1
}
Every hook in the project sources this file. Changes to output formatting, logging, or denial messages propagate to all hooks at once. Debugging is easier because the behavior is consistent across hooks.
Distribution and Team Adoption
Once the hook system is working locally, the distribution strategy determines who else gets it and how.
Project settings.json is the right choice for hooks that should apply to everyone working on this codebase. Commit .claude/settings.json and the hook scripts to the repository. Every contributor who clones the repository gets the full hook system automatically.
Plugin distribution is the right choice for a hook system that should apply across multiple projects. Package the hooks, scripts, and configuration in a plugin. Install the plugin in each project. Updates to the hook system propagate to all projects when the plugin is updated.
Managed settings is the right choice for hooks that must apply to all developers in the organization regardless of which project they are working on. Deploy through MDM. Set allowManagedHooksOnly if the hooks must not be supplemented or overridden by individual or project configuration.
Treat hook scripts like any other code: version control them, test them in isolation with sample JSON payloads, review changes in pull requests, and document what each hook does and why. A hook system that developers understand and trust is one they maintain and improve. A hook system that is opaque and unmaintained gets disabled when it causes problems.
- A three-layer hook architecture covers the full lifecycle — the security gate (PreToolUse) blocks dangerous actions before they happen, the feedback layer (PostToolUse) enforces standards after each tool, and the completion gate (Stop) validates the turn result
- Shared helper scripts eliminate duplication — a lib.sh with parse_stdin(), deny(), and allow_with_log() makes every hook in the project consistent and ensures formatting changes propagate everywhere at once
- Debugging hook systems requires two tools — read the transcript at $transcript_path to see exactly what the hook received, and test scripts independently by piping sample JSON payloads before connecting them to Claude Code
- Distribution choice determines reach — project settings.json shares hooks via git for all contributors, plugins extend hooks across multiple projects, managed settings enforce hooks org-wide with no override possible
- Treat hooks as infrastructure — version control them, test them in isolation, review changes in pull requests, and document the purpose of each hook so the system remains maintainable as the project evolves