Chapter 19: Settings Configuration and the Hooks System
What You'll Learn
By the end of this chapter, you will be able to:
- Describe Claude Code's six-layer configuration hierarchy and the physical storage location of each layer
- Understand the
settingsMergeCustomizerarray-merge strategy and why it differs fundamentally from a plainObject.assign - Explain why enterprise managed settings use "first source wins" while normal settings use deep merge
- Read and understand the four hook command types (command, prompt, agent, http) in
schemas/hooks.tsand their corresponding execution engines - Apply the hook exit code protocol: 0 means success, 2 blocks the model, other values notify the user only
- Write a complete working
PostToolUsehook that sends notifications after tool calls - Understand the keybindings configuration structure and its 17 UI context zones
Claude Code is a highly configurable tool, but its configuration system is considerably more sophisticated than it first appears. When you write a rule in ~/.claude/settings.json, you may not know the exact priority at which it takes effect, which other sources it will be merged with, or under what circumstances an enterprise policy might override it. This chapter lays out the complete picture.
The Hooks mechanism is the most powerful part of the configuration system. It lets you attach arbitrary shell scripts, HTTP requests, or even a small AI agent to the before and after of any tool call, enabling audit logging, workflow integration, security checks, and much more. Once you understand the hooks execution model, you can write automations that genuinely add value.
19.1 The Six-Layer Configuration Structure
Claude Code's configuration is not a single file — it is the result of merging six sources in priority order. Understanding this structure is the foundation for using the configuration system correctly.
These six layers are defined in the SETTING_SOURCES array in .src/utils/settings/constants.ts:7:
// Priority order: later sources override earlier ones
export const SETTING_SOURCES = [
'userSettings',
'projectSettings',
'localSettings',
'flagSettings',
'policySettings',
] as constPlugin settings form an implicit base beneath userSettings and do not appear in this array.
User global settings corresponds to ~/.claude/settings.json (or cowork_settings.json in cowork mode). This is where most personal configuration lives.
Project settings are stored at .claude/settings.json relative to the project root, committed to version control and shared with the team.
Local project settings live at .claude/settings.local.json. Claude Code automatically adds this path to .gitignore, making it the right place for personal overrides that should not be shared — local API key paths, experimental flags, and so on.
CLI flag settings are specified via --settings <path> or injected inline through the SDK. They let automation scripts inject temporary configuration without touching any persistent file.
Enterprise managed settings (policySettings) follow different rules than all the others. Rather than participating in the deep-merge pipeline, they implement a "first source wins" strategy, with priority from highest to lowest:
- Remote managed settings (pushed via the Anthropic API)
- System-level MDM: macOS
com.anthropic.claudecodepreference domain (admin-only); WindowsHKLM\SOFTWARE\Policies\ClaudeCoderegistry key (admin-only) - File-based:
managed-settings.jsonplusmanaged-settings.d/*.jsondrop-in directory - User-writable registry: Windows
HKCU\SOFTWARE\Policies\ClaudeCode(lowest priority, because users can write to HKCU)
This logic lives in getSettingsForSourceUncached at .src/utils/settings/settings.ts:319. Once a non-empty source is found, the function returns immediately without checking lower-priority sources.
19.2 The Deep Merge Strategy
The function that assembles all six layers into a single effective configuration is loadSettingsFromDisk (.src/utils/settings/settings.ts:645). It drives lodash's mergeWith with a custom merge function:
// Arrays are concatenated and deduplicated;
// objects are deep-merged; scalars use the higher-priority value.
export function settingsMergeCustomizer(
objValue: unknown,
srcValue: unknown,
): unknown {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return mergeArrays(objValue, srcValue) // uniq([...target, ...source])
}
return undefined // let lodash handle default merge for non-arrays
}The critical detail here is that arrays are merged and deduplicated, not replaced. Consider:
User global settings (~/.claude/settings.json):
{
"permissions": {
"allow": ["Read(~/projects/**)", "Bash(git status)"]
}
}Project settings (.claude/settings.json):
{
"permissions": {
"allow": ["Write(./**)", "Bash(npm run *)"]
}
}The effective permissions array becomes:
["Read(~/projects/**)", "Bash(git status)", "Write(./**)", "Bash(npm run *)"]For scalar fields like model: "claude-opus-4-5", the higher-priority source simply replaces the lower. For nested objects, lodash performs a recursive deep merge. The result: users never have to worry that a project configuration will wipe out their personal global permission rules, because permission arrays always accumulate.
19.3 The SettingsSchema: A Field Tour
The full structure of the settings file is defined by the SettingsSchema Zod schema in .src/utils/settings/types.ts:255. It uses .passthrough(), which means unknown fields are preserved rather than rejected — an intentional forward-compatibility guarantee.
permissions: Tool-use permission rules (cross-referenced with Chapter 7)
{
"permissions": {
"allow": ["Read(**)", "Bash(git *)"],
"deny": ["Bash(rm -rf *)"],
"ask": ["Write(**/*.prod.*)", "Bash(kubectl *)"],
"defaultMode": "default"
}
}hooks: Hook definitions (the main subject of this chapter, covered below).
model: Override the default model
{ "model": "claude-opus-4-5" }Enterprise administrators can use availableModels (an array of allowed model IDs) to restrict which models users may select.
env: Environment variables injected into all subprocesses
{
"env": {
"GITHUB_TOKEN": "ghp_xxxx",
"NODE_ENV": "development"
}
}disableAllHooks: Emergency kill switch for all hooks and status line scripts
{ "disableAllHooks": true }allowManagedHooksOnly: When set to true in managed settings, all user/project/local hooks are silently ignored; only enterprise-managed hooks execute.
cleanupPeriodDays: Controls transcript retention (default 30 days; 0 disables persistence entirely).
19.4 What Are Hooks?
Hooks are user-defined commands that execute automatically at specific lifecycle points in Claude Code's operation. They are not merely event notifications — a hook can actively influence Claude's behavior: intercepting a tool call before it runs, injecting additional context into the model, or blocking a dangerous operation.
There are two dimensions to understand before writing hooks:
- Trigger points: which lifecycle event fires the hook
- Command types: how the hook is executed
19.5 Hook Event Types
Claude Code currently defines 27 hook events. Their metadata — including what fields the input JSON contains and how each exit code is interpreted — is documented in getHookEventMetadata inside .src/utils/hooks/hooksConfigManager.ts:27. The most important events:
Tool call events
PreToolUse: Fires before a tool executes. Input is a JSON object with the tool's arguments. This is the primary hook point for interception.PostToolUse: Fires after a tool executes. Input contains both the original arguments and the tool's response.PostToolUseFailure: Fires when a tool execution fails, with error details.
Session lifecycle events
SessionStart: Fires when a new session begins.stdoutfrom the hook is shown to Claude, allowing you to inject context at session startup. Thematcherfield filters by session start source:startup,resume,clear, orcompact.SessionEnd: Fires when a session ends; useful for cleanup or telemetry.Stop: Fires just before Claude concludes a turn. Exit code 2 sendsstderrto the model and continues the conversation — useful for post-turn validation.SubagentStop: Same semantics asStop, but for a subagent spawned by the Agent tool.
User interaction events
UserPromptSubmit: Fires when the user submits a prompt. Exit code 2 blocks the entire submission and erases the original prompt.Notification: Fires when the system sends a notification (e.g., a permission dialog appears). Thematcherfield filters bynotification_type.
Conversation management events
PreCompact: Fires before context compaction.stdoutis appended as custom compaction instructions.PostCompact: Fires after compaction completes, receiving the generated summary.
File system events
CwdChanged: Fires when the working directory changes. The hook can write bashexportstatements to a special$CLAUDE_ENV_FILEpath to inject environment variables into subsequent Bash tool calls.FileChanged: Fires when a watched file changes. Thematcherfield specifies which filenames to watch.
Enterprise collaboration events (requires supporting features)
TeammateIdle: Fires when a teammate is about to go idle. Exit code 2 keeps the teammate working.TaskCreated/TaskCompleted: Fires during task lifecycle transitions.PermissionRequest: Fires when a permission dialog is displayed. The hook can return JSON to programmatically allow or deny without user interaction.
19.6 Hook Command Types and Their Schemas
Each hook event can bind multiple commands, and each command takes one of four forms, all defined in .src/schemas/hooks.ts.
command (Shell Command)
The most commonly used type. Executes an arbitrary shell command:
{
type: 'command',
command: 'jq -r .tool_name', // the shell command
if: 'Bash(*)', // optional: only run when tool matches
shell: 'bash', // 'bash' or 'powershell', default: bash
timeout: 30, // seconds; default 10 minutes
async: false, // run in background without blocking
asyncRewake: false, // async + wake model on exit code 2
statusMessage: 'Logging...', // text shown in spinner while running
once: false, // auto-remove after first execution
}The if field uses the same permission rule syntax described in Chapter 7 (Bash(git *), Write(**/*.ts), etc.). When the tool call does not match, the hook is not even spawned — this avoids unnecessary subprocess creation.
prompt (LLM Prompt Hook)
Sends the hook input to a small language model and expects a structured response:
{
type: 'prompt',
prompt: 'Is the following bash command safe to run? $ARGUMENTS',
model: 'claude-haiku-4-5', // uses small fast model if not specified
timeout: 30,
statusMessage: 'Checking safety...',
}The model must respond with {"ok": true} (allow) or {"ok": false, "reason": "..."} (block). The $ARGUMENTS placeholder in the prompt is replaced with the hook input JSON.
agent (Agentic Verifier Hook)
Launches a full subagent that can use tools — reading files, running commands — to verify a condition:
{
type: 'agent',
prompt: 'Verify that all unit tests ran and passed.',
model: 'claude-haiku-4-5', // uses Haiku by default
timeout: 60, // default 60 seconds, max 50 agent turns
}The agent must call a special SyntheticOutputTool to return its verdict. Implementation is in .src/utils/hooks/execAgentHook.ts:36. Subagent-spawning tools and plan mode tools are explicitly blocked to prevent infinite recursion.
http (HTTP Request Hook)
POSTs the hook input JSON to a remote endpoint:
{
type: 'http',
url: 'https://hooks.example.com/audit',
headers: {
'Authorization': 'Bearer $MY_TOKEN'
},
allowedEnvVars: ['MY_TOKEN'], // required to enable env var interpolation
timeout: 10,
statusMessage: 'Sending to audit log...',
}HTTP hooks have a layered security model (implemented in .src/utils/hooks/execHttpHook.ts):
allowedEnvVarscontrols which environment variables can be interpolated into header values. Variables not in this list are replaced with empty strings, preventing secret exfiltration through project-configured hooks.- Enterprise policy can restrict allowed URL patterns via
allowedHttpHookUrlsin managed settings. - Built-in SSRF protection validates that the resolved IP is not in private RFC 1918 ranges. This check is bypassed when a proxy (sandbox or env-var proxy) is in use, since the proxy handles DNS resolution for the target.
- Header values are sanitized to strip CR/LF/NUL bytes, preventing HTTP header injection attacks.
19.7 The Exit Code Protocol
This is the most important and most often misunderstood part of the hooks system. Shell command hooks communicate with Claude Code through their exit code:
PreToolUse exit codes:
| Exit code | Meaning |
|---|---|
| 0 | Allow the tool call; stdout/stderr not shown |
| 2 | Block the tool call; send stderr to the model as the reason |
| Other | Allow the tool call; show stderr to the user (model does not see it) |
PostToolUse exit codes:
| Exit code | Meaning |
|---|---|
| 0 | Success; stdout visible in Transcript mode (Ctrl+O) |
| 2 | Immediately send stderr to the model (can trigger follow-up processing) |
| Other | Show stderr to the user only |
Stop exit codes:
| Exit code | Meaning |
|---|---|
| 0 | Allow Claude to conclude the turn |
| 2 | Send stderr to the model and continue the conversation |
| Other | Show stderr to the user |
The exit code 2 behavior transforms hooks from passive observers into active participants: a PreToolUse hook can block a command with a human-readable explanation that the model reads and responds to, and a Stop hook can force Claude to keep working if a condition has not been satisfied.
Beyond exit codes, hooks can output structured JSON for fine-grained control. For example, a PermissionRequest hook can output:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": "allow"
}
}to programmatically approve a permission dialog without user interaction. A PermissionDenied hook can output {"hookSpecificOutput": {"hookEventName": "PermissionDenied", "retry": true}} to tell the model it may retry the denied operation.
19.8 The Hooks JSON Configuration Format
Inside settings.json, hooks are structured as follows:
{
"hooks": {
"<EventName>": [
{
"matcher": "<optional string pattern>",
"hooks": [
{ "type": "command", "command": "..." },
{ "type": "http", "url": "..." }
]
}
]
}
}The matcher field is optional. For PreToolUse and PostToolUse, it matches the tool_name. For Notification, it matches notification_type. For SessionStart, it matches the session start source (startup, resume, clear, or compact). Omitting the matcher means the hooks in that block run for all instances of that event.
A complete example with multiple events:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/log-bash.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/notify-write.sh",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/on-stop.sh"
}
]
}
]
}
}19.9 Complete Worked Example: A PostToolUse Notification Hook
Here is a complete, working example that sends a desktop notification whenever Claude writes a file.
Step 1: Create the hook script
# ~/.claude/hooks/notify-write.sh
#!/usr/bin/env bash
# PostToolUse hooks receive event data on stdin as JSON.
# For PostToolUse, the shape is:
# { "tool_name": "Write", "tool_input": {...}, "tool_response": {...} }
INPUT=$(cat)
# Extract the path of the file that was written
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // "unknown file"')
# Send a platform-appropriate desktop notification
if command -v osascript &> /dev/null; then
# macOS
osascript -e "display notification \"Claude wrote: $FILE_PATH\" with title \"Claude Code\""
elif command -v notify-send &> /dev/null; then
# Linux with libnotify
notify-send "Claude Code" "Claude wrote: $FILE_PATH"
fi
# Exit 0: success; stdout is visible in Transcript mode (Ctrl+O)
echo "Notification sent for: $FILE_PATH"
exit 0chmod +x ~/.claude/hooks/notify-write.shStep 2: Register the hook in settings.json
Edit ~/.claude/settings.json (global) or .claude/settings.json in your project:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/notify-write.sh",
"timeout": 10,
"async": true,
"statusMessage": "Sending notification..."
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/notify-write.sh",
"timeout": 10,
"async": true
}
]
}
]
}
}Using async: true sends the notification in the background without blocking Claude from continuing. If the notification fails (non-zero exit code), only the stderr output is shown to the user — the tool call result is unaffected.
Step 3: Verify the hook is active
Run /hooks inside Claude Code to see all currently active hooks. Run /status to see the overall configuration status.
Extended example: Slack audit logging
Replacing desktop notifications with Slack messages is straightforward and useful for team audit trails:
# ~/.claude/hooks/audit-tool-use.sh
#!/usr/bin/env bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // "N/A"')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# SLACK_HOOK_URL is injected via the env section of settings.json
if [ -n "$SLACK_HOOK_URL" ]; then
curl -s -X POST "$SLACK_HOOK_URL" \
-H 'Content-type: application/json' \
--data "{
\"text\": \"*Claude Code* | Tool: \`$TOOL_NAME\` | Target: \`$FILE_PATH\` | Session: $SESSION_ID | $TIMESTAMP\"
}" > /dev/null
fi
exit 0Configuration:
{
"env": {
"SLACK_HOOK_URL": "https://hooks.slack.com/services/T.../B.../..."
},
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/audit-tool-use.sh",
"async": true
}
]
}
]
}
}Note the absence of a matcher field: this hook fires for every tool call, not just file writes.
19.10 The Security Model of Hooks
Hooks are the most permission-sensitive part of Claude Code's configuration system. The threat model is worth understanding explicitly.
Who can define hooks?
Any configuration source can define hooks: user settings, project settings, local settings, and enterprise managed settings. This means a malicious checked-in project (.claude/settings.json) could theoretically inject arbitrary shell commands that execute on tool calls.
This is not an oversight. Claude Code's trust model requires the user to explicitly confirm trust for each project via the Trust Dialog before project-level hooks take effect. Hooks from untrusted projects are ignored.
Enterprise lock-down: allowManagedHooksOnly
In managed settings, administrators can set:
{
"allowManagedHooksOnly": true
}This causes all hooks from user, project, and local settings to be silently ignored. Only hooks defined in the managed settings are executed. This is the standard approach for high-compliance environments. The enforcement logic is in .src/utils/hooks/hooksSettings.ts:96.
Finer-grained surface locking: strictPluginOnlyCustomization
Administrators can restrict hooks to plugin-provided sources only:
{
"strictPluginOnlyCustomization": ["hooks", "skills", "mcp"]
}With hooks in this array, only plugin-provided hooks are allowed. All hooks fields in user, project, and local settings are ignored. This composes with strictKnownMarketplaces to create an end-to-end admin-controlled extension model: the marketplace allowlist gates which plugins can be installed, and strictPluginOnlyCustomization ensures only those vetted plugins can contribute hooks.
HTTP hook security details
The execHttpHook implementation (.src/utils/hooks/execHttpHook.ts) layered security:
- URL allowlist: If
allowedHttpHookUrlsis set in managed settings, HTTP hooks targeting non-matching URLs are blocked before any network I/O occurs. - Env var allowlist:
allowedEnvVarson the hook itself defines which environment variables may be interpolated into header values. Variables not in the list are replaced with empty strings. If managed settings additionally sethttpHookAllowedEnvVars, the effective set is the intersection of the two lists. - Header sanitization: CR, LF, and NUL bytes are stripped from all header values to prevent HTTP header injection (CRLF injection) attacks.
- SSRF protection: All HTTP hook requests pass through an SSRF guard that rejects private/link-local IP ranges. This is bypassed when a proxy (sandbox or env-var proxy) is in use.
19.11 Inside the Hook Execution Engine
The core hook execution logic lives in .src/utils/hooks.ts. Here is an outline of the command-type hook path:
Environment setup
The hook subprocess receives the hook input as JSON on stdin, runs in the current working directory (getCwd()), and inherits the session's environment variables via subprocessEnv(). This means any variables set in settings.json's env block are available to hook scripts.
The if condition pre-filter
Before spawning any subprocess, each hook command's if field (if present) is evaluated against the current tool call using the permission rule matcher. A hook with if: "Bash(git *)" will not spawn for Bash(npm install). This avoids unnecessary process creation overhead and keeps hook output clean.
Async hooks
A hook with async: true is fired and immediately moves on — Claude Code does not wait for it to exit. asyncRewake: true adds one behavior on top: if the background process exits with code 2, the model is woken up with the stderr content as an error. This supports patterns like background verification that can interrupt Claude when something goes wrong.
The once flag
A hook with once: true is automatically removed from the hook registry after its first execution. This is useful for setup-style hooks that should only run during initialization.
Prompt and agent hook response format
Both prompt and agent type hooks require a structured JSON response: {"ok": true} to pass or {"ok": false, "reason": "..."} to fail. For agent hooks, this response must be returned via a call to SyntheticOutputTool. If the agent fails to call the tool within 50 turns (the hard maximum), the hook is treated as cancelled — neither blocking nor failing, just a no-op. This timeout behavior prevents runaway agent hooks from hanging Claude indefinitely.
19.12 Custom Keyboard Shortcuts
The keyboard shortcut system is relatively self-contained. The configuration file is ~/.claude/keybindings.json, validated by KeybindingsSchema in .src/keybindings/schema.ts:214.
The structure is an object with a bindings array of context-specific blocks:
{
"$schema": "https://json.schemastore.org/claude-code-keybindings.json",
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+k": "chat:externalEditor",
"ctrl+l": null
}
},
{
"context": "Global",
"bindings": {
"ctrl+shift+t": "command:todos",
"ctrl+shift+c": "command:compact"
}
}
]
}Setting a key to null unbinds the default shortcut for that key.
The 17 context zones (from KEYBINDING_CONTEXTS in .src/keybindings/schema.ts:12):
Global (active everywhere), Chat (when the chat input is focused), Autocomplete, Confirmation, Help, Transcript, HistorySearch, Task, ThemePicker, Settings, Tabs, Attachments, Footer, MessageSelector, DiffDialog, ModelPicker, Select, Plugin.
A binding in Global takes effect no matter which component has focus. A binding in Chat only activates when the chat input box is focused.
Binding values can be:
- A predefined action name from
KEYBINDING_ACTIONS(e.g.,"chat:submit","app:interrupt","voice:pushToTalk") — currently around 70 actions are defined - A command invocation string matching the pattern
command:<name>(e.g.,"command:compact"is equivalent to typing/compactin the input box) nullto unbind a default shortcut
The default bindings are defined in .src/keybindings/defaultBindings.ts. User-defined keybindings are loaded from disk and merged on top, with user bindings taking precedence.
Key Takeaways
Claude Code's configuration system comprises six layers assembled from highest to lowest priority: plugin base, user global settings, project settings, local project settings, CLI flag settings, and enterprise managed settings. The first five participate in a deep merge where array fields are concatenated and deduplicated rather than replaced. Enterprise managed settings follow a "first source wins" rule, selecting the highest-priority non-empty source and ignoring the rest.
The Hooks system is the most powerful extension point in this configuration model. With four command types (command, prompt, agent, http) and 27 event trigger points, hooks can observe or actively intervene at any lifecycle stage of Claude Code's operation. Exit code 2 is the key signal: it lets a hook block an action or inject information into the model mid-conversation.
Security is explicit throughout. Project-level hooks require user trust confirmation. Enterprise environments can restrict hooks to managed-only or plugin-only sources. HTTP hooks have URL allowlists, environment variable allowlists, header sanitization, and SSRF protection built in.
Keyboard shortcuts are configured via ~/.claude/keybindings.json, with support for 17 UI context zones, around 70 predefined actions, slash-command invocations, and null bindings to remove defaults.