Claude Code Hooks: Automate Your Coding Workflow in 2026
Claude Code hooks are shell commands that fire automatically when specific events happen during your coding session. Format code after every edit. Block dangerous commands before they run. Send a notification when Claude finishes a task. All without relying on the LLM to remember to do it.
Hooks give you deterministic control over Claude Code’s behavior. The LLM handles the creative work. Hooks handle the rules that must never be broken.
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 communicates back through stdout, stderr, and exit codes.
There are 27 hook events as of April 2026, covering everything from session start to context compaction. The most useful ones:
- PreToolUse — fires before Claude runs a tool (write file, execute command, etc.). Can block the action.
- PostToolUse — fires after a tool succeeds. Cannot undo, but can trigger follow-up actions.
- Notification — fires when Claude sends a notification. Use for desktop alerts or Slack pings.
- Stop — fires when Claude finishes responding. Use 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.
For most use cases, command hooks are all you need.
Blocking dangerous commands
The most powerful hook pattern: prevent 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. Claude sees the reason and adjusts its approach. 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 in production. Every time Claude writes or edits a file, Prettier formats it automatically:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
This is deterministic. Claude does not need to remember to run the formatter. Every file edit gets formatted, every time.
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"
fi
PostToolUse 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
fi
PreToolUse hooks override permission mode. A hook returning deny blocks the tool even in bypassPermissions mode. Hooks are more restrictive than permission settings, never less.
FAQ
How many hooks can I have?
No hard limit. When multiple hooks match the same event, they run in parallel. Identical commands are deduplicated automatically. Keep hooks fast — 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. Hooks communicate through stdout, stderr, and exit codes only. They cannot trigger / commands or tool calls. They are a control layer, not an execution layer.
What is the difference between hooks and skills?
Skills are markdown files that tell Claude what to do and in what order — they guide the LLM’s decision-making. Hooks are shell commands that enforce rules deterministically — they run regardless of what the LLM decides. Use skills for workflows. Use hooks for guardrails.
I’m documenting the full Claude Code setup — hooks, skills, MCP servers, the automation layer — in my Build & Automate community. Production configs, not tutorials.
This post was published using Notipo — my Notion-to-WordPress sync tool. Write in Notion, publish to WordPress automatically.