Claude Code settings.json Deep Dive (Part 3): The Hooks System
What are hooks
Every time Claude executes a tool — reading a file, writing code, running a shell command — it passes through fixed lifecycle checkpoints: before execution, after execution, and when the session ends. Hooks let you attach your own scripts to these checkpoints: run a safety check before Claude touches anything, or automatically lint after it writes a file.
This is the highest-leverage part of Claude Code’s configuration system — the part that most concretely makes Claude work by your rules.
Configuration structure
hooks is a top-level field in settings.json, at the same level as permissions:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo 'about to run bash' >> /tmp/claude.log"
}
]
}
],
"PostToolUse": [...],
"Stop": [...],
"Notification": [...]
}
}
The structure is three levels deep:
hooks
└── event name (e.g. PreToolUse)
└── [] HookMatcher array
├── matcher: rule string (optional)
└── hooks: [] HookCommand array
Each event can have multiple matchers, and each matcher can have multiple commands.
Four HookCommand types
| Type | Description | Use cases |
|---|---|---|
command | Execute a shell command | Logging, lint, notifications, guards |
prompt | Call an LLM to make a judgment | Semantic review, intelligent checks |
agent | Spawn a sub-agent to handle it | Complex automation workflows |
http | Send an HTTP POST to a URL | Webhooks, external system integration |
command covers the vast majority of everyday use cases. The other three are for more advanced scenarios.
Full fields for the command type:
{
"type": "command",
"command": "your-shell-command",
"shell": "bash",
"timeout": 30,
"statusMessage": "Checking...",
"async": false,
"asyncRewake": false,
"once": false
}
timeout: timeout in secondsstatusMessage: spinner text shown while the hook runsasync: true: run in the background, don’t block ClaudeasyncRewake: true: run in background; if exit code is 2, wake the modelonce: true: run once then automatically remove itself
Core events
Claude Code has 26+ hook events. Here are the four most practical ones for everyday use.
PreToolUse — before tool execution
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/path/to/check.sh" }]
}
]
When it fires: Claude is about to call a tool, before anything executes.
JSON received on stdin:
{
"hook_event_name": "PreToolUse",
"session_id": "...",
"cwd": "/your/project",
"transcript_path": "/tmp/transcript.jsonl",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf dist/" },
"tool_use_id": "..."
}
JSON you can output on stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"additionalContext": "Deleting dist/ is not allowed"
}
}
permissionDecision accepts "allow", "deny", or "ask".
Exit code semantics:
| Exit code | Effect |
|---|---|
0 | Success — no output shown |
2 | Block tool execution — stderr is sent to the model |
| Other | Show stderr to user only — tool continues executing |
Key capability: exit code 2 combined with permissionDecision: "deny" in the JSON output completely blocks the tool call. This is more flexible than permission rules because your script can implement any logic you want.
PostToolUse — after tool execution
"PostToolUse": [
{
"matcher": "Write",
"hooks": [{ "type": "command", "command": "pnpm lint --fix" }]
}
]
When it fires: after a tool has successfully completed.
JSON received on stdin:
{
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"inputs": { "file_path": "src/foo.ts", "content": "..." },
"response": { "type": "text", "text": "File written successfully" },
"tool_use_id": "..."
}
JSON you can output on stdout:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "lint auto-fixed"
}
}
Exit code semantics:
| Exit code | Effect |
|---|---|
0 | Success — output visible in transcript mode |
2 | Send stderr to the model immediately |
| Other | Show stderr to user only |
Stop — before the session ends
"Stop": [
{
"hooks": [{ "type": "command", "command": "osascript -e 'display notification \"Claude finished\"'" }]
}
]
When it fires: Claude is about to conclude its current response.
JSON received on stdin:
{
"hook_event_name": "Stop",
"stop_hook_active": false,
"last_assistant_message": "Done — I've applied all the changes."
}
When exit code is 2: Claude reads the stderr content and continues the conversation. This enables a pattern like “automatically check before stopping, and if something’s wrong, keep going.”
Stop has no matcher — it fires on every stop event.
Notification — notification events
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Ping.aiff" }]
}
]
When it fires: when Claude sends a notification — permission requests, auth prompts, etc.
The matcher matches notification_type. Common values include permission_prompt and auth_success.
This event is fire-and-forget — it doesn’t block execution. It’s designed for side effects like sound alerts and system notifications.
stdin/stdout protocol
Each hook script communicates with Claude Code via standard I/O:
- stdin: one line of JSON containing the event name, session_id, cwd, transcript_path, and event-specific fields
- stdout: output JSON to control behavior, or plain text (which gets recorded in the transcript)
If the script outputs {"async":true} as its first line, it immediately backgrounds itself and Claude continues without waiting.
Available environment variables
Hook scripts have access to these environment variables at runtime:
| Variable | Description |
|---|---|
CLAUDE_PROJECT_DIR | Stable project root directory (not the worktree path) |
CLAUDE_ENV_FILE | Path to a .sh file where you can write export VAR=val (bash only) |
CLAUDE_PLUGIN_ROOT | Plugin/skill directory path |
Matcher format
Matchers reuse the same rule syntax as permissions:
"Write"— match the Write tool exactly"Bash(git *)"— match Bash calls with git commands"Bash(npm:*)"— legacy prefix matching- omitted or empty — match all calls
Practical configuration examples
Auto-lint after file writes:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "cd $CLAUDE_PROJECT_DIR && pnpm lint --fix",
"timeout": 30
}
]
}
]
}
}
Block dangerous Bash commands:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash /path/to/guard.sh"
}
]
}
]
}
}
guard.sh reads the stdin JSON, checks tool_input.command for dangerous patterns, and calls exit 2 with a reason in stderr if something looks risky.
macOS system notification when Claude finishes:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'"
}
]
}
]
}
}
Log every Bash command to a file:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command' >> /tmp/claude-bash.log",
"async": true
}
]
}
]
}
}
async: true sends the log write to the background, so it doesn’t slow Claude down.
Things to keep in mind
- Workspace trust: all hooks require the workspace to be trusted before they run — hooks are silently skipped in untrusted workspaces
- Layer priority:
policySettings(enterprise) >projectSettings>userSettings>localSettings> plugin hooks; hooks from multiple layers are merged and all run, not overwritten - Timeout: set a reasonable
timeouton each hook to avoid a stuck script blocking Claude indefinitely
Summary
Hooks are the most powerful extension point in Claude Code’s configuration system:
- Four types (
command/prompt/agent/http) cover everything from simple scripts to complex automation PreToolUse+ exit code2lets you inspect and block any tool call before it executesPostToolUsetriggers automatic fixes or validation after a tool completesStoplets you insert logic before Claude wraps up, or fire off notifications- The stdin/stdout JSON protocol gives your scripts precise control over Claude’s next action
Next up: the env field and other miscellaneous settings in settings.json.
More Articles
- Why AI-First Startups Only Need One Programming Language
- cc-ping: Ping All Your Claude Code Configs in One Command
- Shocking! This Tool Lets Programmers Finish 95 Minutes of Work in 4 Minutes! 24x Efficiency Boost
- CCBot - 24x Development Efficiency Boost
- Claude Code /add-dir: The Monorepo Command You Miss
- Claude Code Token-Saving Tip: The Power of the Exclamation Mark
- I Built a Bot That Runs Claude Code From Chat
- Claude Code /btw Command Explained: Quick Side Questions Without Breaking Flow
- Claude Code /compact: Free Up Context, Keep Progress
- Claude Code /config: Every Setting Explained
- Claude Code /context: What's Eating Your Context Window?
- Claude Code /diff: See Exactly What Changed, Turn by Turn
- Claude Code /fast: Same Opus, 2x Speed — Worth It?
- Best Practice for External Knowledge in Claude Code: GitHub MCP + Context7
- Claude Code /hooks: Make AI Follow Your Rules
- Claude Code /init: Generate CLAUDE.md in 10 Seconds
- Claude Code MCP: Give Your AI Access to Any Tool
- Claude Code /memory Explained: Make AI Truly Remember Your Project
- Claude Code /model: Opus vs Sonnet vs Haiku Guide
- Claude Code /permissions: Fine-Grained Control Over What AI Can Do
- Claude Code /plan Explained: Think Before You Code
- Claude Code + Playwright MCP: AI Can Finally "See" the Page
- Claude Code /resume Command Explained: Don't Let Your Conversations Go to Waste
- Claude Code /review: Let AI Do Your Code Review
- Claude Code Skills Explained: Build Your Custom Command Library
- Claude Code /stats: See How Much AI Does For You
- Claude Code /status Command Explained: Your Session Dashboard
- Claude Code /tasks Command Explained: Master Your Background Tasks
- Claude Code /usage Command Explained: Know Your Remaining Quota
- Claude Code /vim: Vim Keybindings in Your AI Coding Assistant
- Claude Code in 2026: The Only Setup Guide You Need
- The Complete Guide to Claude: From Chat to Code to Automation
- Claude Code /agents Explained: Custom AI Sub-Agents, Each with Their Own Role
- Claude Code /doctor Explained: One-Click Diagnostics for Your Dev Environment
- Claude Code /effort Explained: Control How Hard Your AI Thinks
- Claude Code /cost Explained: How Much Is Your AI Coding Really Costing?
- Claude Code /export Explained: Take Your AI Conversations With You
- Claude Code /rewind Explained: AI Made a Mistake? Undo It Instantly
- Claude Code /plugin Explained: Install Plugins for Your AI Coding Assistant
- Claude Code /theme Explained: Give Your Terminal a New Look
- Claude Code /insights: Using AI to Analyze How You Use AI
- Claude Code /rename Explained: Give Your Sessions Meaningful Names
- Claude Code settings.json Explained (1): Where Config Files Live and Who Wins
- Claude Code settings.json Deep Dive (Part 2): The Permissions System
- Claude Code settings.json Deep Dive (4): env, Models, Auth, and Other Useful Fields