Claude Code hooks: a practical guide
Claude Code hooks are the difference between an agent you hope behaves and one you can prove won't run
rm -rf on the wrong directory. They're shell commands that fire at fixed points in the agent's
lifecycle, and a PreToolUse hook can block a tool call before it executes. I run hooks as a
hard enforcement layer in production. This is how they actually work, with config you can copy.
What Claude Code hooks are
Claude Code hooks are user-defined shell commands that Claude Code runs automatically at specific points
in a session, like before a tool runs (PreToolUse), after it runs (PostToolUse),
or when the session starts. You configure them in settings.json. The hook reads a JSON
description of what's happening on stdin and signals back through its exit code and stdout. They run as
real OS processes with your permissions, so they execute deterministically every time, not when the model
feels like it.
That last point is the whole reason to use them. If you put "always run the linter after editing a file"
in CLAUDE.md, it's a suggestion the model follows most of the time. A PostToolUse
hook runs the linter every time, because the harness runs it, not Claude. Anything you need to be
guaranteed rather than likely belongs in a hook.
Hooks live at three scopes: ~/.claude/settings.json for everything you do, a project's
.claude/settings.json committed to the repo, and .claude/settings.local.json for
machine-local overrides you don't commit. Project hooks are the useful ones in a team, because they ship
with the repo and apply to everyone who opens it.
The lifecycle events worth knowing
Claude Code fires hooks on a set of named events. The ones you'll reach for first are
PreToolUse (before a tool call, and the only one that can block it cleanly via permissions),
PostToolUse (after a tool succeeds), UserPromptSubmit (before Claude sees your
prompt), SessionStart (session begins or resumes), and Stop (Claude finishes a
turn). There are more, but these five cover almost every practical hook.
Here's the working subset, with what each one is actually good for:
| Event | Fires | Use it for |
|---|---|---|
PreToolUse | Before a tool call runs | Block or allow a call, validate the input, redirect a command |
PostToolUse | After a tool call succeeds | Format, lint, run tests, log what changed |
UserPromptSubmit | Before Claude reads your prompt | Inject context, block a prompt, add a timestamp |
SessionStart | Session starts, resumes, or clears | Load recent state or open issues into context |
Stop | Claude finishes responding | Force a final check; block the stop to keep it working |
SubagentStop | A subagent finishes | Same as Stop, scoped to spawned subagents |
PreCompact | Before context compaction | Snapshot the transcript before it's summarized |
SessionEnd | Session terminates | Cleanup, final logging |
The matcher on tool events keys off the tool name: Bash, Edit, Write,
Read, or an MCP tool like mcp__github__create_issue. You can match several with a
pipe (Edit|Write) or everything with *. Events that aren't tool calls, like
SessionStart, ignore the matcher or use a source value instead.
The settings.json shape
Hooks go under a top-level "hooks" key in settings.json, keyed by event name.
Each event holds an array of matcher groups, and each group has a matcher string and a list of
hooks to run, where each hook is a command with a type of "command".
The nesting trips people up the first time, so here's the full structure with one
PreToolUse matcher in place:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh",
"timeout": 10
}
]
}
]
}
}
The matcher is the tool name. The inner command is what runs; pointing it at a
script file rather than inlining shell keeps the JSON readable and the script testable.
$CLAUDE_PROJECT_DIR is set by Claude Code to the project root, so the path resolves no matter
which subdirectory the session is working in. timeout is in seconds and optional. That's the
entire schema for a command hook.
How a hook gets data and answers back
Claude Code passes the hook a JSON object on stdin, and the hook answers two ways: through its exit code,
or by printing JSON on stdout. Exit 0 means proceed. Exit 2 is the blocking signal: for
PreToolUse it blocks the tool, and whatever the hook wrote to stderr is fed back to Claude as
the reason. Any other non-zero code is a non-blocking error that gets logged and ignored.
The stdin payload carries the session context and, for tool events, the tool name and its input. A
PreToolUse hook on a Bash call receives something like this:
{
"session_id": "abc123",
"cwd": "/Users/me/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf build/" }
}
Exit codes are the simple path and cover most needs. The three that matter:
| Exit code | Meaning |
|---|---|
0 | Success. Proceed. Stdout JSON (if any) is parsed. |
2 | Block. Stderr is sent to Claude as the reason. On PreToolUse the tool call is stopped. |
| other | Non-blocking error. Logged, shown to you, execution continues. |
For finer control there's a JSON path: print an object on stdout and exit 0. A PreToolUse hook
can return a permissionDecision of "allow", "deny", or
"ask" under a hookSpecificOutput key, which is cleaner than exit 2 when you want
to attach a reason the model reads. The exit-2 route is fine for a yes-or-no guard; reach for JSON when you
want to deny with an explanation or rewrite the tool input.
A real guard: blocking a dangerous command
The highest-value hook is a PreToolUse guard on Bash that inspects the command string and
exits 2 if it matches a destructive pattern. This is the pattern I lean on most: read the proposed command
from stdin, check it against a denylist, block with a reason if it's dangerous, otherwise let it through.
Here's a complete guard script. It reads the JSON payload, pulls out the command with jq, and
refuses recursive force-deletes and a couple of other footguns:
#!/usr/bin/env bash
# .claude/hooks/guard.sh - blocks destructive Bash commands
set -euo pipefail
payload=$(cat)
command=$(echo "$payload" | jq -r '.tool_input.command // ""')
# Patterns we never want an agent to run unattended.
if echo "$command" | grep -Eq 'rm[[:space:]]+-[a-z]*r[a-z]*f|:\(\)\{|mkfs|dd[[:space:]]+if='; then
echo "Blocked: '$command' matches a destructive pattern. Ask the user to run it manually." >&2
exit 2
fi
exit 0
Make it executable (chmod +x .claude/hooks/guard.sh), wire it to PreToolUse with
the Bash matcher from the config above, and every Bash call now passes through it first. When
it exits 2, Claude Code never runs the command and Claude sees the stderr line, so it knows why and can
adjust instead of looping. The denylist here is deliberately small. In practice you tune the patterns to
your repo, but the shape stays the same: parse, match, exit 2 with a reason.
tool_input.command. Matching the tool name alone would block every Bash call, which is
useless.
A PostToolUse formatter
The second hook most people want runs a formatter after Claude edits a file, so the code is always
formatted without asking. A PostToolUse hook on Edit|Write fires after the file
is written, reads the path from the payload, and runs your formatter on it.
This one doesn't need to block anything, so it can be a one-liner in the config. It runs Prettier on whatever file Claude just touched:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs -r npx prettier --write"
}
]
}
]
}
}
The xargs -r matters: if jq returns nothing, -r stops Prettier from
running on an empty argument and erroring. Small detail, but without it the hook throws a non-blocking
error on every tool call that has no file path, and your transcript fills with noise.
Hooks as enforcement, not convenience
In a multi-tenant agent system, hooks are the enforcement layer that keeps one tenant's agent inside its
own boundary, not a productivity nicety. We run a PreToolUse guard that inspects every tool
call against a per-tenant policy and blocks anything that reaches outside the allowed paths or touches a
secret, before the call executes. The model's cooperation isn't part of the security model.
The pattern generalizes well beyond our setup. Keep a small policy file (we use a JSON registry that maps what each agent is allowed to touch) and have the guard load it, check the proposed tool call against it, and exit 2 on a violation. The model can be prompted to stay in bounds, and it usually does, but "usually" isn't a boundary. The hook is. It runs on every call, in process you control, with a default of deny for anything not explicitly allowed.
This is the same principle that holds when you build an MCP server that wraps a writable API: the safety check lives in code you control and runs before the side effect, not in the prompt. A hook is that idea applied one level up, at the agent's own tool calls rather than inside a single server. Both come down to enforcing the boundary where the action actually happens, with a deny default, instead of trusting the model to behave.
Gotchas that cost me time
- Hooks run with your full shell permissions. A hook is arbitrary code you wrote, running as you. A bug in a hook can do real damage, so treat hook scripts like any other code you'd let near your filesystem. Review them, keep them in version control, don't paste hooks from random gists without reading them.
- Exit 1 doesn't block. Only exit 2 blocks. I lost an hour wondering why my guard's exit 1 let commands through. One is a generic error and gets logged and ignored; two is the blocking signal. Use two.
- The matcher matches the tool, not the content. A matcher of
Bashfires on every Bash call. To act on what the command does, you have to readtool_inputinside the script. The matcher is a coarse filter, not the decision. - Settings load at session start. Editing
settings.jsonmid-session doesn't always pick up new hooks. Restart the session, or check/hooksto confirm what's actually registered before you assume a hook is live. - Quote your variables. The command string can contain spaces, quotes, and shell metacharacters Claude generated. Unquoted
$commandin a guard is its own injection risk. Quote everything, and prefer matching withgrepovereval.
Hooks are the part of Claude Code that turns it from a clever assistant into something you can put real guardrails around. The model is the flexible part. The hooks are where you make the non-negotiable things non-negotiable, in code that runs every time whether the model cooperates or not.
Pavle Lazic is the founder of Scalably, where he builds and runs multi-tenant Claude agent platforms in production for real businesses. He writes about the Claude Agent SDK, MCP servers, and what it actually takes to put AI agents to work. See the platform.