Logo Vincent
Back to all posts

Claude Code settings.json Deep Dive (Part 3): The Hooks System

Claude
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

TypeDescriptionUse cases
commandExecute a shell commandLogging, lint, notifications, guards
promptCall an LLM to make a judgmentSemantic review, intelligent checks
agentSpawn a sub-agent to handle itComplex automation workflows
httpSend an HTTP POST to a URLWebhooks, 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 seconds
  • statusMessage: spinner text shown while the hook runs
  • async: true: run in the background, don’t block Claude
  • asyncRewake: true: run in background; if exit code is 2, wake the model
  • once: 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 codeEffect
0Success — no output shown
2Block tool execution — stderr is sent to the model
OtherShow 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 codeEffect
0Success — output visible in transcript mode
2Send stderr to the model immediately
OtherShow 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:

VariableDescription
CLAUDE_PROJECT_DIRStable project root directory (not the worktree path)
CLAUDE_ENV_FILEPath to a .sh file where you can write export VAR=val (bash only)
CLAUDE_PLUGIN_ROOTPlugin/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 timeout on 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 code 2 lets you inspect and block any tool call before it executes
  • PostToolUse triggers automatic fixes or validation after a tool completes
  • Stop lets 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

© 2026 vincentqiao.com . All rights reserved.