Skip to content

Chapter 19: Settings Configuration and the Hooks System

What You'll Learn

By the end of this chapter, you will be able to:

  1. Describe Claude Code's six-layer configuration hierarchy and the physical storage location of each layer
  2. Understand the settingsMergeCustomizer array-merge strategy and why it differs fundamentally from a plain Object.assign
  3. Explain why enterprise managed settings use "first source wins" while normal settings use deep merge
  4. Read and understand the four hook command types (command, prompt, agent, http) in schemas/hooks.ts and their corresponding execution engines
  5. Apply the hook exit code protocol: 0 means success, 2 blocks the model, other values notify the user only
  6. Write a complete working PostToolUse hook that sends notifications after tool calls
  7. 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:

typescript
// Priority order: later sources override earlier ones
export const SETTING_SOURCES = [
  'userSettings',
  'projectSettings',
  'localSettings',
  'flagSettings',
  'policySettings',
] as const

Plugin 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:

  1. Remote managed settings (pushed via the Anthropic API)
  2. System-level MDM: macOS com.anthropic.claudecode preference domain (admin-only); Windows HKLM\SOFTWARE\Policies\ClaudeCode registry key (admin-only)
  3. File-based: managed-settings.json plus managed-settings.d/*.json drop-in directory
  4. 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:

typescript
// 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):

json
{
  "permissions": {
    "allow": ["Read(~/projects/**)", "Bash(git status)"]
  }
}

Project settings (.claude/settings.json):

json
{
  "permissions": {
    "allow": ["Write(./**)", "Bash(npm run *)"]
  }
}

The effective permissions array becomes:

json
["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)

json
{
  "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

json
{ "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

json
{
  "env": {
    "GITHUB_TOKEN": "ghp_xxxx",
    "NODE_ENV": "development"
  }
}

disableAllHooks: Emergency kill switch for all hooks and status line scripts

json
{ "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. stdout from the hook is shown to Claude, allowing you to inject context at session startup. The matcher field filters by session start source: startup, resume, clear, or compact.
  • SessionEnd: Fires when a session ends; useful for cleanup or telemetry.
  • Stop: Fires just before Claude concludes a turn. Exit code 2 sends stderr to the model and continues the conversation — useful for post-turn validation.
  • SubagentStop: Same semantics as Stop, 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). The matcher field filters by notification_type.

Conversation management events

  • PreCompact: Fires before context compaction. stdout is 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 bash export statements to a special $CLAUDE_ENV_FILE path to inject environment variables into subsequent Bash tool calls.
  • FileChanged: Fires when a watched file changes. The matcher field 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:

typescript
{
  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:

typescript
{
  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:

typescript
{
  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:

typescript
{
  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):

  • allowedEnvVars controls 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 allowedHttpHookUrls in 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 codeMeaning
0Allow the tool call; stdout/stderr not shown
2Block the tool call; send stderr to the model as the reason
OtherAllow the tool call; show stderr to the user (model does not see it)

PostToolUse exit codes:

Exit codeMeaning
0Success; stdout visible in Transcript mode (Ctrl+O)
2Immediately send stderr to the model (can trigger follow-up processing)
OtherShow stderr to the user only

Stop exit codes:

Exit codeMeaning
0Allow Claude to conclude the turn
2Send stderr to the model and continue the conversation
OtherShow 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:

json
{
  "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:

json
{
  "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:

json
{
  "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

bash
# ~/.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 0
bash
chmod +x ~/.claude/hooks/notify-write.sh

Step 2: Register the hook in settings.json

Edit ~/.claude/settings.json (global) or .claude/settings.json in your project:

json
{
  "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:

bash
# ~/.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 0

Configuration:

json
{
  "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:

json
{
  "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:

json
{
  "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 allowedHttpHookUrls is set in managed settings, HTTP hooks targeting non-matching URLs are blocked before any network I/O occurs.
  • Env var allowlist: allowedEnvVars on 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 set httpHookAllowedEnvVars, 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:

json
{
  "$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 /compact in the input box)
  • null to 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.

Built for learners who want to read Claude Code like a real system.