Appearance
Hooks
Every tool call Claude makes, reading a file, running a command, editing code, passes through an event system you can hook into. Before a tool runs, after it completes, when a session starts, when the user submits a prompt: over twenty event types fire at predictable moments, and each one is an interception point where you can inspect, block, transform, or log what Claude is about to do.
Hooks are the mechanism that turns Claude Code from a tool you supervise by watching the terminal into a system with guardrails you've encoded in advance. A hook that blocks rm -rf doesn't rely on you catching it mid-session. A hook that logs every Bash call to a file doesn't require you to scroll back through terminal output. A hook that prompts you for confirmation before Claude pushes to main doesn't depend on Claude remembering to ask.

What you'll learn
- Hooks fire on 20+ events: session lifecycle (SessionStart, Stop), user prompts (UserPromptSubmit), tool use (PreToolUse, PostToolUse), and notifications
- Four hook types: command (run a script), HTTP (call a webhook), prompt (inject text into Claude's context), and agent (delegate to a subagent)
- Exit code 2 from a command hook blocks the action, everything else is an affirmative signal
- Hooks are defined in
settings.jsonunderhooks.<EventName>, with matchers and conditions for fine-grained targeting
The problem
You're pairing with Claude on a production codebase. It's running shell commands, editing files, making git commits. Each of those actions carries risk, and the only thing between Claude and a mistake is your attention, which is finite. You can't watch every tool call. You can't review every proposed edit in real time. And you definitely can't guarantee you'll notice when Claude runs a dangerous command until after it's run.
Permissions (allow/ask/deny) give you coarse-grained control: you can block all Bash commands, or allow them, or require approval for each one. But permissions apply uniformly to every invocation of a tool, regardless of context. A hook, by contrast, fires on a specific event and can inspect the tool's arguments before deciding what to do. A hook can allow git status while flagging git push --force. A hook can allow file writes to src/ while blocking writes outside the project tree. That's the difference between a gate and a filter.
Options & when to use each
Claude Code supports four hook types, and each is configured differently in settings.json:
| Hook type | What it does | Configuration | Use when |
|---|---|---|---|
| Command | Runs a shell script. Exit code 2 = block. Stdout gets injected as context. | { "type": "command", "command": "./hooks/pre-commit-check.sh" } | Validating, blocking, or transforming tool arguments with custom logic |
| HTTP | Sends a POST request with event payload as JSON body. Response ignored. | { "type": "http", "url": "https://hooks.slack.com/..." } | Logging, alerting, triggering external workflows |
| Prompt | Injects text into Claude's context at the hook point | { "type": "prompt", "prompt": "Remember: no hardcoded secrets." } | Reminders, guardrails injected at specific moments |
| Agent | Delegates to a Claude subagent that runs with its own tool access | { "type": "agent", "prompt": "Review this diff for security issues." } | Deferred analysis, review loops |
A matcher restricts a hook to specific tools or patterns. Without a matcher, the hook fires on every event of that type. With a matcher, you target specific tool names or argument patterns:
json
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "./hooks/block-dangerous-commands.sh",
"matcher": "Bash"
}
]
}
}This hook fires only before Bash tool calls, not before Read, Write, Edit, or any other tool. A matcher that's a string (like "Bash") matches by tool name. A matcher that's an object can match on specific arguments.
An if condition adds a second layer of filtering using a JavaScript expression evaluated against the hook input:
json
{
"type": "command",
"command": "./hooks/block-force-push.sh",
"matcher": "Bash",
"if": "input.command.includes('push --force') || input.command.includes('push -f')"
}This fires only on Bash calls whose command string contains a force push.
Build it
Step 1: A blocking hook that prevents dangerous commands
Create .claude/settings.json in your project root (or add to an existing one):
json
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "bash -c 'echo \"$CLAUDE_TOOL_INPUT\" | grep -qE \"rm -rf /|sudo rm|chmod 777 /\" && exit 2 || exit 0'",
"matcher": "Bash"
}
]
}
}This hook runs before every Bash tool call. It checks the tool input (available as $CLAUDE_TOOL_INPUT in the environment) against a regex of dangerous patterns. If it matches, the script exits with code 2, and Claude Code blocks the tool call and reports the block to the user.
Test it by launching a session and attempting a command that matches your regex filter. The agent will intercept the call and notify you that execution was blocked.
bash
claude # start interactive session
> Run `rm -rf /tmp/test-dir` # harmless but matches the pattern
# Expected: Claude reports "Tool use blocked by hook" and does not run the commandStep 2: A logging hook that records every tool call
Capture an audit trail of agent activity by hooking into the post-execution lifecycle. This dumps tool arguments and timestamps into a flat file for later review.
json
{
"hooks": {
"PostToolUse": [
{
"type": "command",
"command": "bash -c 'echo \"$(date -Iseconds) | $CLAUDE_TOOL_NAME | $CLAUDE_TOOL_INPUT\" >> /tmp/claude-tool-log.txt'"
}
]
}
}This fires after every tool completes, successful or not, and appends a timestamped log entry. The environment variables $CLAUDE_TOOL_NAME and $CLAUDE_TOOL_INPUT carry the tool name and its arguments. There is also $CLAUDE_TOOL_OUTPUT for PostToolUse hooks, containing whatever the tool returned.
After a session, check the log:
bash
cat /tmp/claude-tool-log.txt
# 2026-07-21T14:23:01+00:00 | Read | /home/user/project/src/main.py
# 2026-07-21T14:23:05+00:00 | Bash | pytest tests/
# 2026-07-21T14:23:12+00:00 | Write | /home/user/project/src/main.pyStep 3: A prompt-injection hook for session reminders
Automatically supply critical context at startup. This injects text straight into the agent's memory window, establishing constraints before it processes the first prompt.
json
{
"hooks": {
"SessionStart": [
{
"type": "prompt",
"prompt": "This is the `payments` service. Never log full credit card numbers. Use masked values (last 4 digits only) in all outputs."
}
]
}
}This injects a reminder into Claude's context at the start of every session. It works like an additional CLAUDE.md entry, but scoped to this project and carried through the hook system rather than a file read. Prompt hooks are cheap: they add text to the context window, no external process runs.
Step 4: The PermissionDecision hook
One hook works differently from the rest: PermissionDecision. Instead of firing before or after a tool call, it fires when Claude encounters a permission gate, when a tool is set to ask and Claude needs your approval. You can use it to auto-approve or auto-deny based on context:
json
{
"hooks": {
"PermissionDecision": [
{
"type": "command",
"command": "bash -c '[[ \"$CLAUDE_TOOL_INPUT\" == *\"git status\"* ]] && exit 0 || exit 1'",
"matcher": "Bash"
}
]
}
}Exit code 0 auto-approves. Exit code 1 auto-denies. Exit code 2 falls through to asking the user. This lets you keep tight permissions ("Bash": "ask") while carving out exceptions for safe commands.
What goes wrong
| Mistake | How you notice it | The fix |
|---|---|---|
| Hook exits 2 on a PreToolUse for a tool Claude needs to complete the task | Claude reports the block, then retries, and hits the same block again, looping | Your hook is too broad. Narrow the matcher or the if condition. A hook that blocks all Bash calls means Claude can't run tests, lint, or git, which means it can't finish most tasks |
| Command hook runs a slow script | Every tool call pauses while the hook runs, making Claude feel sluggish | Hooks run synchronously. Keep command hooks under 200ms. If you need a heavy validation step, use an HTTP hook to trigger it asynchronously instead |
| Hook environment variables missing or empty | The hook script runs but $CLAUDE_TOOL_INPUT is blank | The variables are hook-type-specific. CLAUDE_TOOL_INPUT is available in PreToolUse and PostToolUse but not in SessionStart or Stop. Check the event reference for which variables each event provides |
| JSON in settings.json is invalid | Claude starts but hooks don't fire, or Claude refuses to load the settings file entirely | Validate the JSON before saving: `cat .claude/settings.json |
| PermissionDecision hook auto-approves something dangerous | Claude runs a command you didn't want it to run, and the hook let it through | Audit your PermissionDecision hooks against the full set of commands in the tool category. "If input contains 'git' then approve" approves git push --force origin main as eagerly as git status |
Confirm it worked
Create a test hook and verify it fires, blocks, and logs:
bash
# 1. Write a settings file with a test hook
cat > .claude/settings.json << 'JSONEOF'
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "bash -c 'echo \"BLOCKED: $CLAUDE_TOOL_NAME\" >> /tmp/claude-hook-test.log; [[ \"$CLAUDE_TOOL_INPUT\" == *\"BLOCK_ME\"* ]] && exit 2 || exit 0'",
"matcher": "Bash"
}
]
}
}
JSONEOF
# 2. Test that a normal command passes through
claude -p "Run: echo 'hello hook'" --output-format text
# 3. Test that a blocked command is stopped
claude -p "Run: echo 'BLOCK_ME'" --output-format text
# Expected: Claude reports the tool call was blocked
# 4. Check the log
cat /tmp/claude-hook-test.log
# Expected entries for both commands, showing the hook fired each time
# 5. Clean up
rm .claude/settings.json /tmp/claude-hook-test.logIf step 2 succeeds (echo runs), step 3 shows a block (the flagged command is stopped), and step 4 shows both hook invocations in the log, the hook system is working from definition through execution through blocking. You're ready to build command-level guardrails into every project.
Next: Model Selection & Cost Management, choosing the right model for the job and keeping costs predictable.