Claude Code has a hooks system that most people haven’t touched yet. It’s one of those features that sits quietly in the docs, looks optional, and then turns out to solve a whole category of friction once you start using it.
The concept is simple: you can configure shell commands that run before or after any Claude Code tool call. The agent writes a file — your formatter runs. The agent executes a command — your logger captures it. The agent tries to do something outside your defined scope — your hook exits with an error and the operation is blocked.
Hooks aren’t new in software — CI/CD pipelines, git, and package managers have used them for decades. What’s different here is you’re attaching them to an AI agent’s tool use rather than to a developer’s manual action. That changes the use cases.
How Hooks Work
Hooks live in your Claude Code settings file, under a hooks key. You define a list of hooks, each specifying which tool or tool event triggers it, and what command to run. The two timing options are PreToolUse (runs before the tool executes, with the ability to block the operation) and PostToolUse (runs after, with access to the tool’s output).
A basic configuration looks like this in settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "prettier --write \"$CLAUDE_TOOL_INPUT_PATH\" 2>/dev/null || true"
}
]
}
]
}
}
This runs Prettier on any file Claude writes. The $CLAUDE_TOOL_INPUT_PATH variable is injected by Claude Code with the path from the Write tool’s input. The || true ensures a formatting failure doesn’t block the write — adjust that to your preference.
Environment Variables
Hooks receive context about the tool call through environment variables:
CLAUDE_TOOL_NAME— which tool was called (Write,Edit,Bash, etc.)CLAUDE_TOOL_INPUT— the full tool input as JSONCLAUDE_TOOL_INPUT_PATH— thepathfield from the input, pre-extracted for file toolsCLAUDE_TOOL_OUTPUT— the tool’s output (PostToolUse only)CLAUDE_SESSION_ID— the current session identifier
For PreToolUse hooks, exiting with a non-zero status code blocks the tool from running and returns your hook’s stderr as an error message to Claude Code. This is how you enforce restrictions.
Useful Patterns
Auto-format on write:
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT_PATH\" 2>/dev/null || true"
}
]
}
This catches both Write (new files) and Edit (modifications). Use a pipe-separated list in the matcher to cover multiple tools.
Run tests after file changes:
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [[ \"$CLAUDE_TOOL_INPUT_PATH\" == *.test.* ]]; then npm test --testPathPattern=\"$CLAUDE_TOOL_INPUT_PATH\" 2>&1 | tail -20; fi'"
}
]
}
This runs tests when Claude writes or edits a test file. Scoping it to test files avoids running the full suite on every edit.
Block writes to sensitive paths (PreToolUse):
{
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash -c 'if [[ \"$CLAUDE_TOOL_INPUT_PATH\" == .env* || \"$CLAUDE_TOOL_INPUT_PATH\" == *credentials* ]]; then echo \"Blocked: cannot write to credential files\" >&2; exit 1; fi'"
}
]
}
]
}
A non-zero exit from a PreToolUse hook blocks the operation entirely. The stderr message surfaces in Claude Code’s output.
Log all Bash executions:
{
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo \"[$(date -Iseconds)] BASH: $(echo $CLAUDE_TOOL_INPUT | jq -r .command)\" >> ~/claude-audit.log"
}
]
}
]
}
This builds an audit trail of every shell command Claude Code runs. Useful if you’re running Claude Code in an environment where you want a record of what happened, or when debugging unexpected behaviour.
Scope: Project vs User
Hooks can be configured at two levels. User-level hooks in ~/.claude/settings.json apply to all Claude Code sessions. Project-level hooks in .claude/settings.json (committed to your repo) apply only in that project.
The project-level configuration is where team-wide automation makes sense. If your project uses a specific formatter, linter, or test runner, put the hooks there so every developer using Claude Code in that repo gets consistent behaviour automatically.
Matcher Syntax
The matcher field accepts a pipe-separated list of tool names. Available tools you can match against include:
Write— new file creationEdit— file modificationsRead— file reads (less common to hook, but possible)Bash— shell command executionGlob— file pattern searchesGrep— content searchesAgent— subagent spawning
You can also use * to match all tools.
What Hooks Are Good For
The honest summary is that hooks shine in three situations:
Enforcement — things that should always happen when a file is modified (formatting, linting) that you’d otherwise have to remember to run manually or tell Claude to run explicitly each time.
Guardrails — PreToolUse hooks let you define hard boundaries that Claude Code cannot cross regardless of instructions. If you’re running Claude Code with auto-accept enabled and want to prevent writes to certain directories, a hook is a reliable mechanism.
Observability — if you’re using Claude Code in any context where you want a record of what was done (shared environments, code that affects production, security-sensitive workflows), PostToolUse hooks give you structured logging without needing to parse conversation history.
For straightforward interactive sessions, hooks are optional overhead. For teams, for automation, or for anyone who’s wished Claude Code would just run Prettier without being asked every single time — they’re worth the fifteen minutes of configuration.