Claude Code hooks: a practical guide
What hooks are, the PreToolUse/PostToolUse/Stop events, how to wire them in settings.json, a complete working command-logger example, and the pitfalls that quietly disable them.
What a hook actually is
A Claude Code hook is a shell command (or a prompt evaluated by the model) that the CLI runs at a fixed point in the agent's tool loop — before a tool call, after one completes, when the agent stops responding, and a handful of other lifecycle points. The hook receives a JSON payload on stdin describing what's happening, and its exit code and output feed back into the loop.
That's the whole mechanism. There's no plugin API to learn, no SDK to import — a hook is any executable that can read JSON from stdin and exit with a meaningful code. This is what makes hooks powerful for guardrails: you can enforce a rule ("never read .env", "never force-push") with a twenty-line bash script instead of trusting a system prompt instruction to hold under pressure.
The events that matter
Claude Code exposes several hook events; three cover almost every real use case.
- PreToolUse — fires before a tool call executes, with the proposed tool name and input. This is the only event that can actually stop something from happening: exit code 2 blocks the call before it runs. Use it for guardrails — secret-file reads, destructive shell commands, spend caps.
- PostToolUse— fires after a tool call finishes, with both the input and the result. The call already happened; you can't undo it from here. Use it for side effects that should track every tool use — audit logging, async notifications, cache invalidation.
- Stop — fires when the agent is about to finish responding and hand control back to the user (or, for a scripted
claude -prun, about to exit). A common pattern is a Stop hook that checks whether a completion condition is actually met and, if not, feeds the agent a reason to keep going instead of stopping early.
A few other events exist for narrower cases — SessionStart to inject context at the beginning of a session, SubagentStop for a review step when a delegated subagent finishes, and Notification / PreCompact for more specialized moments. The three above are where nearly all hook logic ends up living.
Wiring a hook in settings.json
Hooks are declared in .claude/settings.json (project-level) or ~/.claude/settings.json (user-level), grouped by event. Each event takes a list of matcher groups — a matcher pattern (a tool name, a regex, or omitted entirely to match every tool) paired with the commands to run when it matches:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/command-logger.sh" }
]
}
]
}
}This registers command-logger.sh to run before every Bash tool call. The hook script receives the tool-call payload on stdin and can inspect, log, or reject it.
A complete working example: a command logger
The smallest genuinely useful hook is an audit logger — it never blocks anything, so there's no risk of breaking a session, but it gives you a durable, greppable record of every shell command an agent ran. Here's a complete, dependency-free version:
#!/usr/bin/env bash
# .claude/hooks/command-logger.sh
#
# Logs every Bash command a session runs to a local JSONL file. Wired under
# PreToolUse so the log captures intent even if the command later fails or
# the session is killed mid-run. No external dependencies — see "dependency
# fail-open" below for why that matters.
input=$(cat)
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Pull "command" out of the tool_input JSON without requiring jq. Good
# enough for a logger; a guard that BLOCKS on this extraction would need
# the fail-closed handling described further down.
cmd=$(printf '%s' "$input" \
| sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(.*\)".*/\1/p' \
| head -n1)
log_dir="${CLAUDE_PROJECT_DIR:-.}/.claude/logs"
mkdir -p "$log_dir"
printf '{"ts":"%s","command":"%s"}\n' "$ts" "$cmd" >> "$log_dir/commands.jsonl"
exit 0Make it executable (chmod +x on macOS/Linux; on Windows, Git Bash respects the executable bit from the shebang line as long as the file itself is invoked with bash, which is what the command field above does), wire it as shown, and every Bash tool call in the session appends one JSON line to .claude/logs/commands.jsonl — a file you can tail, grep, or feed into a dashboard.
Pitfalls that quietly disable a hook
Matcher scoping
A matcher of "Bash" matches only the Bash tool — not Read, not Edit, not a subagent's internal tool calls. If you want a rule to apply everywhere (a spend guard that should block any paid action regardless of which tool triggers it), use ".*" or omit the matcher — and then make the hook itself cheap to run on every call, since it now runs far more often. Conversely, the most common bug in a new hook setup isn't the script logic — it's a matcher typo ("bash" instead of "Bash") that makes the hook never fire at all, silently.
The exit-code contract
Exit code 0 means allow; stdout is only shown to the user in transcript mode. Exit code 2 is the blocking signal — for a PreToolUse hook it denies the tool call and feeds stderr back as the reason. Any other non-zero exit code is a non-blocking error: the user sees it, but the tool call proceeds anyway. Writing an error message to stdout instead of stderr, or exiting 1 when you meant to block, is an easy way to end up with a hook that logs a warning nobody reads while the risky action goes through unimpeded.
CRLF on Windows
bad interpreter or syntax error the first time it runs on a machine that clones fresh — the shebang line becomes #!/usr/bin/env bash\r, and that trailing carriage return is enough to break it.If your team develops on a mix of Windows and Unix machines, add a .gitattributes entry forcing LF line endings for everything under .claude/hooks/ (e.g. .claude/hooks/*.sh text eol=lf), and set core.autocrlf consistently rather than relying on every contributor's local git config. A hook that silently fails to run because of line endings is functionally identical to a hook that was never installed — except it looks installed, which is worse, because nobody double-checks something that appears to be there.
Dependency fail-open
If a hook shells out to a tool that might not be installed on every machine it runs on — jq is the classic one for JSON parsing — decide explicitly what happens when that tool is missing. The tempting default is usually the wrong one: a script that does cmd=$(echo "$input" | jq -r .tool_input.command) with no fallback will, on a host without jq, have cmd come back empty, match no block pattern, and exit 0 — allowing everything through with no error, no warning, nothing. A guardrail hook should fail closed: either bundle a dependency-free fallback (as the example above does, with sed instead of jq), or treat a failed extraction as reason to block and say so, rather than silently becoming a no-op. This exact failure mode — a guard hook that quietly stopped enforcing anything because jqwasn't on the host — is common enough in the wild that it's worth testing for directly: run your hook once on a machine (or a shell) with the dependency deliberately removed from PATH and confirm it still does the right thing.
This guide covers the concept; the Autonomous Agent Ops Kit packages the full implementation — three production guard hooks (spend cap, secret-read blocking, destructive-command denial) with fail-closed fallback logic, their install docs, and a worked subagent-routing setup that uses hooks to enforce review at handoff points.