Claude Code Hooks (2026): Setup Guide with Working Examples
Claude Code hooks are shell commands that fire automatically when specific events happen during your coding session. Format code after every edit. Block a dangerous command before it runs. The LLM never has to remember any of it, and that is the whole point. Hooks give you deterministic control over Claude Code’s…

Claude Code hooks are shell commands that fire automatically when specific events happen during your coding session. Format code after every edit. Block a dangerous command before it runs. The LLM never has to remember any of it, and that is the whole point: hooks give you deterministic control over Claude Code’s behavior while the model gets on with the creative work.
How hooks work
A hook is a shell command tied to a lifecycle event. When the event fires, your command runs. The command receives context as JSON on stdin, and answers with an exit code and whatever it prints.
There are 27 hook events as of April 2026, from session start to context compaction. The ones that matter most:
- PreToolUse: fires before Claude runs a tool (write file, execute command, etc.). Can block the action.
- PostToolUse: runs after a tool succeeds. It cannot undo anything, but it can kick off follow-up work.
- Notification: fires when Claude sends a notification. Desktop alerts, Slack pings.
- Stop: Claude has finished responding. Good for cleanup or verification.
- UserPromptSubmit: fires when you submit a prompt, before Claude processes it.
Where to configure hooks
Hooks live in your settings.json file. There are four scope levels:
~/.claude/settings.json → global (all projects)
.claude/settings.json → project (committable)
.claude/settings.local.json → project (gitignored)
Managed policy settings → organization-wide
The JSON structure:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}The matcher field filters which tools trigger the hook. Edit|Write means the hook only runs when Claude edits or writes a file. Leave the matcher empty to match all tools.
Four hook types
Each hook has a type that determines how it runs:
1. command: Runs a shell command. Input via stdin (JSON), output via stdout/stderr and exit codes. Default timeout: 600 seconds.
{
"type": "command",
"command": "bash .claude/hooks/format-on-save.sh"
}2. http: POSTs event data as JSON to a URL. Default timeout: 30 seconds. Good for webhooks and external integrations.
{
"type": "http",
"url": "https://your-webhook.example.com/hook"
}3. prompt: Sends the hook input to a Claude model (Haiku by default) for single-turn evaluation. Returns allow or deny with a reason. Default timeout: 30 seconds.
4. agent: Spawns a subagent that can use tools (read files, run commands) to verify conditions before returning a decision. Up to 50 tool-use turns. Default timeout: 60 seconds.
command covers nearly everything you will want.
Blocking dangerous commands
This is the pattern worth setting up first: stop Claude from doing something it should not do. A PreToolUse hook can block any tool call by exiting with code 2.
#!/bin/bash
# .claude/hooks/block-dangerous.sh
# Block destructive git commands and .env edits
TOOL_NAME=$(jq -r '.tool_name' < /dev/stdin)
TOOL_INPUT=$(jq -r '.tool_input | tojson' <<< "$INPUT")
# Block .env file edits
if echo "$TOOL_INPUT" | jq -r '.file_path // empty' | grep -q '\.env'; then
echo "Blocked: cannot edit .env files" >&2
exit 2
fi
# Block force push
if echo "$TOOL_INPUT" | jq -r '.command // empty' | grep -q 'push.*--force'; then
echo "Blocked: force push not allowed" >&2
exit 2
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/block-dangerous.sh"
}
]
}
]
}
}Exit code 2 blocks the tool and shows Claude the error message from stderr, so it sees the reason and adjusts. Any other non-zero exit code logs an error but does not block execution.
When multiple hooks return conflicting decisions, the most restrictive wins: deny beats defer beats ask beats allow.
Auto-format code after every edit
The most common hook. Every time Claude writes or edits a file, Prettier formats it:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}Claude never has to remember to run the formatter.
Desktop notifications
Get a macOS notification when Claude needs your attention:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs attention\" with title \"Claude Code\"'"
}
]
}
]
}
}On Linux, replace osascript with notify-send. On Windows, use PowerShell’s MessageBox.
Reload environment variables on directory change
If you use direnv or switch between projects with different environment variables:
{
"hooks": {
"CwdChanged": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "direnv export bash >> \"$CLAUDE_ENV_FILE\""
}
]
}
]
}
}The CLAUDE_ENV_FILE environment variable points to a file where you can write export VAR=val lines. These persist across Bash commands in the session.
Modifying tool input
PreToolUse hooks can rewrite what Claude is about to do. Return updatedInput in the JSON output to replace the tool’s input:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": {
"command": "npm run lint",
"timeout": 120000
}
}
}This replaces the entire input object. You must provide all fields, not just the ones you want to change. If multiple hooks modify the same tool’s input, the last one to finish wins (they run in parallel), so avoid overlapping input modifications.
Gotchas that will bite you
Shell profile echo statements break hooks. Hooks run in non-interactive shells that source your ~/.zshrc or ~/.bashrc. If your profile has unconditional echo statements, they prepend text to hook stdout and break JSON parsing. Fix:
if [[ $- == *i* ]]; then
echo "Welcome back"
fiPostToolUse cannot undo actions. The tool already ran. Use PreToolUse if you need to block something.
Output is capped at 10,000 characters. If your hook generates more, it gets truncated before being injected into Claude’s context.
Stop hooks can loop. The Stop event fires whenever Claude finishes responding. If your Stop hook causes Claude to respond again, it fires again. Always check stop_hook_active from stdin. If true, exit immediately:
ACTIVE=$(jq -r '.stop_hook_active // false')
if [ "$ACTIVE" = "true" ]; then
exit 0
fiPreToolUse hooks override permission mode. A hook returning deny blocks the tool even in bypassPermissions mode.
FAQ
How many hooks can I have?
No hard limit. When multiple hooks match the same event, they run in parallel, and identical commands are deduplicated automatically. Keep them fast, because every millisecond adds to Claude’s response time.
Do hooks work with Claude Code on the web?
Yes. The CLAUDE_CODE_REMOTE environment variable is set to "true" in web sessions and unset in CLI. You can branch your hook logic based on this if needed.
Can hooks call Claude Code tools directly?
No. A hook only talks back through its output and its exit code, so it cannot trigger / commands or tool calls.
What is the difference between hooks and skills?
Skills are markdown files that tell Claude what to do and in what order, so they guide the LLM’s decision-making. Hooks are shell commands that enforce rules deterministically, whatever the LLM decides. That makes skills the place for workflows and hooks the place for guardrails.
I’m documenting the full Claude Code setup in my Build & Automate community: hooks, skills, MCP servers, the automation layer. Production configs, not tutorials.
Related Reading
- Run a Claude Code Agent in Production: How to run Claude Code as a real production agent, with observability, retries, secrets, drift handling.
- Write Claude Code Agent Skills That Actually Work: Custom skills that consistently fire and produce useful output.
- Give Claude Code Agents Real Autonomy: Letting Claude Code take action on its own without 3 a.m. surprises.
Related
How I Give Claude Code Agents Real Autonomy (And Stop 3am Disasters)
Running Claude Code agents unsupervised is only scary if you haven’t drawn the lines. I’ve had agents push to the wrong branch, draft content I didn’t want published, and retry failed jobs in loops that burned rate limits at 2am. I solved all of it by sorting actions into classes: what the agent may do…
How to Use Claude Code Subagents to Run Parallel Tasks
I’ve used Claude Code subagents daily for the past few months. They are what made Claude Code scale for me: several tasks running at once, without my main session losing the thread. Here is how subagents work, when I spawn one, and the parallel-task patterns I keep coming back to in…
Build a Custom MCP Server in Python in 2026
I’ve built several MCP servers in Python: WordPress publishing, Skool community management, YouTube analytics. They all follow the same pattern, and the point of each one was to stop copy-pasting data into prompts and let Claude Code call the system directly. Here’s how to build one from scratch with the official SDK, including the parts…