Claude Code settings.json Deep Dive (Part 3): The Hooks System
Table of Contents
- What are hooks
- Configuration structure
- Four HookCommand types
- Core events
- PreToolUse — before tool execution
- PostToolUse — after tool execution
- Stop — before the session ends
- Notification — notification events
- stdin/stdout protocol
- Available environment variables
- Matcher format
- Practical configuration examples
- Things to keep in mind
- Summary
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.
Related Articles
Claude Code Agent Loop: Dissecting the Heart of an AI Coding Assistant
How does Claude Code understand your requests, invoke tools, and self-recover step by step? A source-code deep dive into the Agent Loop's core architecture — streaming responses, parallel tool execution, auto-compaction, and error recovery.
Claude Code settings.json Explained (1): Where Config Files Live and Who Wins
A complete guide to Claude Code's configuration file system — five config sources, their file paths, priority rules, array merging vs value overriding, and enterprise managed settings delivery.
Claude Code settings.json Deep Dive (Part 2): The Permissions System
A thorough breakdown of Claude Code's permissions configuration — allow/deny/ask rule arrays, wildcard syntax, MCP tool permissions, defaultMode options, and additionalDirectories.
Claude Code settings.json Deep Dive (4): env, Models, Auth, and Other Useful Fields
A comprehensive guide to the remaining settings.json fields in Claude Code — env variable injection, model configuration, authentication helpers, Git attribution, session cleanup, language and UI, thinking depth, auto-updates, memory system, and more.
Claude Code /agents: Give Each Task Its Own Specialist AI
Create custom sub-agents for code exploration, architecture planning, and more — each with its own role, tools, and instructions.