Skip to main content

Hooks & Lifecycle Automation

16 min read

What You’ll Learn

This chapter walks you through Claude Code’s hook system, which lets you automate actions at specific points in the tool’s lifecycle. We’ll cover all 24 lifecycle events (split across three categories), the four handler types you can use to respond to events, configuration format with the conditional if field for fine-grained filtering, matcher patterns, exit code semantics, the hookSpecificOutput schema for PreToolUse decision control (including the defer decision for SDK workflows), the PermissionDenied event and its connection to Auto Mode, the hook output size cap, and how to manage hooks interactively.

By the end, you’ll be able to configure hooks that lint code before commits, block dangerous commands, send notifications on task completion, log denied auto mode actions, and enforce project-specific workflows.

What Are Hooks?

Hooks are shell commands that fire automatically at specific points in the Claude Code lifecycle, enabling CI-style automation directly within the agentic workflow.

Hooks are automations that run at specific points in Claude Code’s lifecycle. They fire in response to events such as a session starting, a tool about to run, a permission being denied, or a prompt being submitted, and can do just about anything: run a shell script, call an HTTP endpoint, evaluate an LLM prompt, or hand things off to a subagent.

Think of them as CI style automation baked right into your Claude Code workflow. Instead of waiting for a push to kick off a pipeline, hooks trigger the moment the event happens. You can lint code before Claude commits it, block destructive shell commands before they run, log every tool invocation to an audit system, track permission denials in auto mode, or ping your team when Claude wraps up a task.

You configure hooks in settings files (user, project, or managed), and they apply to all sessions within their scope. You can also embed hooks in skill and subagent frontmatter for skill scoped automation; see Custom Skills for that pattern.

Hook Lifecycle Overview

Claude Code fires 24 lifecycle events across three categories: session events that bookend the overall session, loop events that fire during the agentic loop, and standalone async events that fire independently of the main loop.

Hook lifecycle: 26 event types across session, loop, and async categories

The diagram above shows the full lifecycle flow. Session events (SessionStart and SessionEnd) wrap the entire interaction. In between, the agentic loop fires events as Claude gathers context, takes actions, handles permissions, and verifies results. Standalone async events fire independently whenever configuration changes, instructions load, worktrees get created, files change, or the working directory shifts.

Getting a feel for which events fire and in what order is the foundation for writing effective hooks. The next three sections break down each category.

Here’s a quick look at hooks in action, a SessionStart hook fires at startup, and PreToolUse hooks fire before each tool call:

SessionStart and PreToolUse hooks firing in sequence
View as text (accessible transcript)
$ claude

Session hook active. All tool calls will be logged

> Check if there are any TODO comments in the codebase

Hook approved: Grep
Searched for TODO patterns...

Hook approved: Read
Read src/routes/auth.ts

Found 3 TODO comments:
- src/routes/auth.ts: TODO: hash password before storing
- src/middleware/auth.ts: TODO: load public paths from config
- src/index.ts: TODO: add rate limiting middleware
SessionStart and PreToolUse hooks firing in sequence

Event Reference

Claude Code’s 24 lifecycle events fall into three categories. The table below is the full reference. After it, we’ll walk through representative examples of the most commonly used events.

EventCategoryTriggerKey Payload FieldsCan Block
SessionStartSessionNew session begins or resumessession_id, session_type, resumeNo
SessionEndSessionSession terminatessession_id, duration_ms, turn_countNo
UserPromptSubmitLoopUser submits a promptprompt, session_id, turn_numberNo
PreToolUseLoopBefore a tool call executestool_name, tool_input, session_idYes
PermissionRequestLoopPermission dialog about to showtool_name, permission_type, session_idNo
PermissionDeniedLoopTool call denied by permission systemtool_name, tool_input, deny_reason, permission_mode, retryNo
PostToolUseLoopAfter a tool call completestool_name, tool_input, tool_output, exit_codeNo
PostToolUseFailureLoopAfter a tool call failstool_name, tool_input, error, exit_codeNo
NotificationLoopClaude sends a notificationmessage, level, session_idNo
SubagentStartLoopA subagent is spawnedsubagent_id, task, parent_session_idNo
SubagentStopLoopA subagent completessubagent_id, result, duration_msNo
StopLoopClaude finishes respondingstop_reason, session_id, turn_numberNo
TeammateIdleLoopAgent team member goes idleteammate_id, last_task, idle_sinceNo
TaskCompletedLoopA task is marked completetask_id, task_description, session_idNo
PreCompactLoopBefore context compactioncontext_tokens, threshold, session_idNo
ElicitationLoopBefore MCP elicitation prompt showsserver_name, message, schema, session_idNo
ElicitationResultLoopAfter user responds to elicitationserver_name, action, response, session_idNo
InstructionsLoadedStandaloneCLAUDE.md or rules file loadedfile_path, file_type, content_lengthNo
ConfigChangeStandaloneSettings file modifiedconfig_path, change_type, session_idNo
WorktreeCreateStandaloneGit worktree createdworktree_path, branch, session_idNo
WorktreeRemoveStandaloneGit worktree removedworktree_path, branch, session_idNo
CwdChangedStandaloneWorking directory changesold_cwd, new_cwd, session_idNo
FileChangedStandaloneFile system change detectedfile_path, change_type, session_idNo
PermissionDeniedStandaloneTool call denied (async notification)tool_name, tool_input, deny_reason, permission_modeNo

Notice that PreToolUse is the only event that can block execution. Every other event is purely observational. They let you react to what happened, but they can’t prevent it.

Session Events

Session events fire once at the start and end of each Claude Code session.

SessionStart fires when Claude Code initializes a new session, before any user prompt gets processed. It’s great for environment setup: checking prerequisites, loading environment variables, starting background services, or recording the session start time for metrics.

settings.json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo \"Session started at $(date)\" >> ~/.claude/session.log"
}
]
}
]
}
}

A SessionStart hook that logs when each session begins.

SessionEnd fires when the session is winding down, after the final response. It’s your chance to clean up: stop background services, upload session logs, or send a summary notification.

Loop Events

Loop events fire during the agentic loop as Claude processes prompts and uses tools. There are 15 of them in total. Five are critical for most hook workflows; the rest handle more specialized needs.

UserPromptSubmit fires when the user submits a prompt, before Claude starts processing it. It’s useful for prompt preprocessing, input validation, or logging user activity. This event fires for every prompt in a session, including follow up messages.

PreToolUse fires right before Claude executes a tool. This is the only loop event that can block execution. When a PreToolUse hook returns exit code 2 (or a deny decision via hookSpecificOutput), the tool call gets blocked and Claude receives feedback explaining why. That makes PreToolUse your go to event for safety gates: blocking destructive commands, enforcing file access rules, or requiring approval for sensitive operations.

settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-command.sh"
}
]
}
]
}
}

A PreToolUse hook that validates Bash commands before execution. Only PreToolUse can block.

PostToolUse fires after a tool finishes executing. It’s handy for post processing: logging tool results, validating outputs, or triggering follow up actions. Keep in mind that PostToolUse can’t block anything, since it runs after the fact.

Stop fires when Claude decides it’s done with the task and is about to exit the agentic loop. Use it for quality checks: running a final lint pass, verifying test coverage, or confirming the output meets your requirements.

PreCompact fires before Claude auto-compacts the conversation to free up context window space. You can use it to inject important context that should survive compaction, or to save a snapshot of the conversation state before information gets compressed.

Additional Loop Events

The remaining loop events cover more specialized use cases. Most hook configurations won’t need all of them, but they enable powerful integrations when you do.

EventWhen It Fires
PermissionRequestClaude asks the user for tool permission
PermissionDeniedA tool call is denied by the permission system (including auto mode)
PostToolUseFailureA tool execution fails with an error
NotificationClaude sends a notification (e.g., task progress)
SubagentStartA subagent is spawned
SubagentStopA subagent completes its work
TeammateIdleA teammate in an agent team becomes idle
TaskCompletedA task in an agent team is marked complete
ElicitationAn MCP server requests user input via elicitation
ElicitationResultThe user responds to an MCP elicitation prompt

Standalone Async Events

Seven events fire independently of the main agentic loop. They respond to changes in Claude Code’s configuration, workspace, and environment rather than to user prompts or tool calls.

InstructionsLoaded fires when Claude Code loads instruction files (CLAUDE.md, settings.json, etc.) at session startup or whenever those files change during a session. You can use it to validate instructions or log which configuration files are active.

ConfigChange fires when a settings file gets modified during a session. It’s useful for reloading custom configuration, notifying the user about setting changes, or validating that new settings are compatible.

WorktreeCreate fires when a new git worktree is created (via the —worktree flag or subagent isolation). It’s your opportunity to set up the new worktree environment: installing dependencies, copying configuration files, or initializing databases.

WorktreeRemove fires when a git worktree is removed. Use it for cleanup: removing temporary files, closing database connections, or archiving worktree logs.

CwdChanged fires when the working directory changes during a session. Use it to update environment variables, reload project-specific configuration, or log directory transitions for auditing.

FileChanged fires when file system changes are detected in the project. Use it to trigger rebuilds, update caches, or notify external systems about file modifications.

PermissionDenied also fires as a standalone async event, allowing external integrations to track denied actions without blocking the main loop.

Explore Events Interactively

Click any event below to see its payload fields, handler types, and configuration examples.

Configuration Format

Hook configuration follows a three level nesting structure: the event name maps to an array of matcher groups, and each matcher group contains an array of hook handlers.

settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-bash.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-write.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/setup-env.sh"
}
]
}
]
}
}

Two PreToolUse matcher groups (Bash and Write) and one SessionStart hook without a matcher.

Here’s how the three levels break down:

  1. Event name (e.g., PreToolUse, SessionStart), which determines the lifecycle event that triggers the hooks.
  2. Matcher group (the objects in the event’s array), which filters which specific instances of the event should trigger this group’s hooks. If there’s no matcher field, the hooks run on every instance of the event.
  3. Hook handlers (the objects in the hooks array), the actual automations that run when the event fires and the matcher matches.

You can configure hooks at three scopes:

  • User settings (~/.claude/settings.json): hooks apply to all your sessions on this machine.
  • Project settings (.claude/settings.json): hooks apply to all sessions in this project. Commit them to Git for team-wide hooks.
  • Managed settings: hooks enforced by organization administrators. Users can’t override these.

The Conditional if Field

Hook handlers support an if field that uses permission rule syntax to narrow firing beyond what the matcher provides. This avoids spawning a process just to check tool arguments, which can measurably reduce hook overhead for high-frequency events.

The if field is supported on PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied events. It uses the same syntax as permission rules: ToolName(argument pattern).

settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/lint-before-commit.sh",
"if": "Bash(git commit *)"
}
]
}
]
}
}

This hook only fires for Bash tool calls that match 'git commit *'. Other Bash calls skip it entirely.

Without the if field, this hook would fire on every Bash tool call, then the script itself would need to parse the command and bail out for non-commit calls. With if, Claude Code handles the filtering before the process even spawns.

settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-production-writes.sh",
"if": "Write(config/production/*)"
}
]
}
]
}
}

Block writes to production config files using the if field for path-based filtering.

Matcher Patterns

Matchers filter which instances of an event trigger the associated hooks. Different event types support different matcher fields.

PreToolUse and PostToolUse match on the tool name. The matcher value is a string, either an exact tool name or a regular expression. So when Claude is about to use the Bash tool, a matcher of "Bash" fires. A matcher of "Bash|Write" fires for both Bash and Write tool calls.

Matching multiple tools with regex
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/audit-changes.sh"
}
]
}
]
}
}

This hook fires before both Bash and Write tool calls.

When you don’t specify a matcher, the hooks fire for every instance of the event. For session events (SessionStart, SessionEnd), matchers are typically omitted since these events only fire once per session.

Handler Types

Each hook handler specifies a type that determines how the automation runs. Claude Code supports four handler types.

command

The command handler runs a shell script or command, and it’s by far the most common handler type. The command receives event data as JSON on stdin and can output JSON to stdout to influence Claude Code’s behavior.

.claude/hooks/validate-bash.sh
.claude/hooks/validate-bash.sh
#!/bin/bash
# Reads the tool input from stdin, checks for dangerous patterns
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+/'; then
# Output JSON to deny the command
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked: rm -rf / is not allowed"
}
}'
exit 2 # Blocking error
fi
# Command is safe, allow it
exit 0

A command handler that blocks rm -rf / via exit code 2 and hookSpecificOutput.

HTTP

The HTTP handler sends a POST request to a URL with the event data as the JSON body. It’s ideal for integrating with external systems: logging to a webhook, notifying a Slack channel, or calling an internal API.

HTTP handler configuration
{
"type": "http",
"url": "https://hooks.example.com/claude-events"
}

Sends event data as a POST request to the specified URL.

Worth noting: enterprise administrators can restrict which URLs are allowed for HTTP hooks using the allowedHttpHookUrls setting in managed configuration. This prevents hooks from sending data to unauthorized endpoints.

prompt

The prompt handler runs an LLM prompt with the event context. Claude evaluates the prompt and can return a decision or analysis. It’s perfect for nuanced checks that need reasoning beyond simple pattern matching, such as evaluating whether a code change follows project conventions.

Prompt handler configuration
{
"type": "prompt",
"prompt": "Review the following tool call. If the command could delete important data or modify system files, respond with a JSON object containing hookSpecificOutput with permissionDecision set to 'ask'. Otherwise respond with an empty JSON object {}."
}

An LLM evaluates the event and returns a decision.

agent

The agent handler delegates the event to a subagent for more complex analysis. The subagent runs in its own context and can use tools to investigate before returning a response. It’s the right choice for hooks that need multi-step reasoning or file analysis.

Exit Code Semantics

For command handlers, the exit code tells Claude Code how to interpret the result. There are three categories that carry specific meaning.

Exit code 0 means success. The hook ran without issues and Claude Code continues normally. Use exit 0 when your hook’s checks pass or when there’s nothing to act on.

Exit code 2 means blocking error. Claude Code stops the current operation. This only matters for PreToolUse hooks, since that’s the only event that can block. When a PreToolUse hook exits with code 2, the tool call gets prevented and Claude receives the hook’s output as feedback explaining why. For non-blocking events, exit code 2 is treated the same as any other non-zero code.

Any other exit code (1, 3, 4, etc.) means non blocking error. Claude Code logs a warning but keeps going. The hook’s failure doesn’t stop the operation. Use non zero exit codes for hooks that encounter errors but shouldn’t block Claude’s work, such as a logging hook that can’t reach its endpoint.

.claude/hooks/block-rm.sh
.claude/hooks/block-rm.sh
#!/bin/bash
# Block rm -rf commands via PreToolUse
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'rm\s+-rf'; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive rm -rf command blocked by safety hook"
}
}'
exit 2 # BLOCKS the tool call
fi
exit 0 # Allows the tool call

Exit 2 blocks the Bash tool call. Exit 0 allows it. Any other code warns but allows.

JSON Output and Decision Control

Command handlers can output JSON to stdout to communicate decisions and metadata back to Claude Code. The JSON output schema supports five top level fields.

continue is a boolean that controls whether Claude should keep processing. Set it to false to stop the agentic loop after this event.

stopReason is a string explaining why processing should stop. Only relevant when continue is false.

suppressOutput is a boolean that prevents the hook’s output from showing up in Claude’s context. Handy for hooks that log externally and don’t need Claude to see the result.

systemMessage is a string injected as a system message into Claude’s context. Use it to provide feedback, warnings, or instructions that Claude should factor into its next step.

hookSpecificOutput is an object with event specific fields. For PreToolUse, this is the primary mechanism for decision control.

For PreToolUse events, hookSpecificOutput supports four decision values:

  • permissionDecision: “allow” automatically approves the tool call without asking the user.
  • permissionDecision: “deny” blocks the tool call. Claude receives the reason and adjusts its approach.
  • permissionDecision: “ask” presents the tool call to the user for manual approval.
  • permissionDecision: “defer” pauses the session at this tool call. In -p (pipe) mode sessions, Claude exits with a deferred_tool_use payload. SDK applications can capture this payload and resume the session later with --resume. This is designed for asynchronous approval workflows where a human or external system reviews the tool call outside the session.

The permissionDecisionReason field provides an explanation that Claude (or the user, in the case of “ask” decisions) sees alongside the decision.

.claude/hooks/enforce-write-rules.sh
.claude/hooks/enforce-write-rules.sh
#!/bin/bash
# Enforce file write restrictions via PreToolUse
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Block writes to production config
if echo "$FILE_PATH" | grep -qE '^config/production'; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Production config files are read only. Use staging config instead."
}
}'
exit 2
fi
# Auto-approve writes to test files
if echo "$FILE_PATH" | grep -qE '\.test\.(ts|js)$'; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Test files are auto-approved"
}
}'
exit 0
fi
# Everything else goes through normal permission flow
exit 0

Deny writes to production config, auto-approve test files, default flow for everything else.

The defer Decision

The defer decision value is designed for SDK and automation workflows. When a PreToolUse hook returns permissionDecision: "defer", the behavior depends on the session mode:

  • In -p (pipe) mode: Claude exits with a deferred_tool_use payload containing the tool name, input, and session ID. The calling application can review the tool call, then resume the session with --resume to either allow or deny it.
  • In interactive mode: The tool call is presented to the user for manual approval, similar to the “ask” decision.
.claude/hooks/defer-destructive.sh
.claude/hooks/defer-destructive.sh
#!/bin/bash
# Defer destructive operations for external review
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE '(DROP TABLE|DELETE FROM|TRUNCATE)'; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer",
"permissionDecisionReason": "Database mutation requires external approval"
}
}'
exit 0
fi
exit 0

Defer database mutations for external review. SDK applications capture the deferred payload and resume after approval.

Here’s a PreToolUse blocking hook in action. Claude tries to run a destructive command, the hook blocks it, and Claude adapts with a safer alternative:

A PreToolUse hook blocking a dangerous rm -rf command
View as text (accessible transcript)
$ cat .claude/settings.json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/block-rm.sh"
      }]
    }]
  }
}

$ claude

> Clean up all temporary files by running rm -rf /tmp/my-app-cache

BLOCKED: Refusing to run destructive rm command.
Claude adapts and suggests a safer alternative.

> List all TypeScript files in the project

This command runs normally. The hook only blocks rm -rf.
A PreToolUse hook blocking a dangerous rm -rf command

PermissionDenied Event

The PermissionDenied event fires whenever a tool call is denied by the permission system. This is particularly valuable in Auto Mode sessions, where the classifier blocks actions it considers unsafe or out of scope. See Models, Cost Economics & Permissions for how Auto Mode’s classifier works.

The event payload includes the tool name, the input that was blocked, the reason for denial, the active permission mode, and a retry field indicating whether the action can be retried. In auto mode, retry is true, meaning the user can approve the action and resume auto mode.

settings.json
{
"hooks": {
"PermissionDenied": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/log-denied-actions.sh"
}
]
}
]
}
}

Log every permission denial for audit compliance.

.claude/hooks/log-denied-actions.sh
.claude/hooks/log-denied-actions.sh
#!/bin/bash
# Log denied actions with context for security review
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
REASON=$(echo "$INPUT" | jq -r '.deny_reason // "no reason"')
MODE=$(echo "$INPUT" | jq -r '.permission_mode // "unknown"')
echo "[$(date -u +%FT%TZ)] DENIED tool=$TOOL mode=$MODE reason=$REASON" >> ~/.claude/denied-actions.log
# Send to external audit system
curl -s -X POST https://audit.example.com/denied \
-H "Content-Type: application/json" \
-d "$INPUT" || true
exit 0

Log denied actions to a local file and forward to an external audit endpoint.

Using PermissionDenied hooks alongside Auto Mode gives you full visibility into what the classifier blocks, enabling compliance workflows and security auditing without slowing down the development loop.

Hook Output Size Cap

Hook command output is capped at 50,000 characters. When a hook’s stdout exceeds this limit, Claude Code saves the full output to a temporary file on disk and injects a truncated preview plus the file path into the conversation context instead.

This prevents verbose hooks from bloating the context window. If you have hooks that generate large outputs (such as full test suite results or lint reports), design them to output a summary to stdout and write detailed results to a file separately.

Hook Locations and Scopes

Hooks can be defined in six locations. Unlike most settings where later scopes override earlier ones, hooks from all locations merge and all run. If you have a PreToolUse hook in user settings and another in project settings, both execute, and neither overrides the other.

User settings (~/.claude/settings.json): your personal hooks across all projects.

Project settings (.claude/settings.json): team hooks shared via Git.

Local settings (.claude/settings.local.json): your personal hooks for this project only. Not committed to Git.

Managed settings: organization enforced hooks distributed by administrators. Users can’t remove these.

Plugin hooks: hooks provided by installed plugins.

Skill and agent frontmatter: hooks embedded in SKILL.md or AGENT.md hooks field. These only run when that specific skill or agent is active. See Custom Skills for the frontmatter format.

This merging behavior means you can layer hooks: organization-wide safety rules in managed settings, team specific workflow hooks in project settings, and personal productivity hooks in user settings. All three layers run for every event, giving you defense in depth. See Security & Enterprise Administration for governance patterns around hook management in enterprise environments.

Managing Hooks with /hooks

The /hooks slash command gives you an interactive menu for managing hooks without manually editing JSON files. It shows all configured hooks across scopes, lets you add new hooks with guided prompts, and supports deleting hooks you no longer need.

Interactive hook management
/hooks

View, add, and delete hooks from all scopes in an interactive menu.

The /hooks menu is the fastest way to experiment with hooks. Create one interactively, test it, and then move the configuration to a settings file once you’re happy with it. It’s especially useful for debugging, since you can see exactly which hooks are active and which scope they’re coming from.

Best Practices

  • Use PreToolUse for safety gates. It’s the only event that can block execution. Put your most important guardrails here: blocking destructive commands, enforcing file access rules, and requiring approval for sensitive operations.

  • Use the if field to reduce overhead. For hooks that only need to run on specific tool arguments (like git commit commands), the if field filters before process spawn. This matters for high-frequency events like PreToolUse where every millisecond counts.

  • Keep hook scripts fast. Hooks run synchronously and block Claude Code while they execute. A slow hook that takes 5 seconds adds 5 seconds of latency to every matching event. Optimize for speed: avoid network calls in command handlers when possible, and lean on HTTP handlers for async integrations.

  • Use exit codes correctly. Exit 0 for success, exit 2 for blocking errors (PreToolUse only), and any other code for non blocking warnings. Getting this wrong can either block operations you meant to allow or silently pass operations you meant to block.

  • Test hooks in isolation before deploying. Pipe sample JSON into your hook script and verify the output before adding it to settings. A broken hook can disrupt every session.

  • Use matchers to narrow scope. A PreToolUse hook without a matcher fires for every tool call, including Read, Grep, Glob, Bash, Write, and more. Matchers let you target only the tools that actually need validation.

  • Use hookSpecificOutput for decision control. The permissionDecision and permissionDecisionReason fields in hookSpecificOutput are the correct way to control PreToolUse decisions.

  • Layer hooks across scopes for defense in depth. Organization safety rules in managed settings, team workflow hooks in project settings, and personal productivity hooks in user settings all merge and run together.

  • Monitor hook output size. If your hooks generate more than 50K characters of output, the full output gets saved to disk with only a preview in context. Design hooks to output concise summaries.

  • Use PermissionDenied for audit trails. Pair PermissionDenied hooks with Auto Mode for complete visibility into what actions the classifier blocks, enabling compliance and security review workflows.

Further Reading

  • Hooks, the official documentation on lifecycle events, configuration format, handler types, and decision control
  • Next chapter: Git Worktrees & Subagent Delegation covers parallel development branches and custom agent personas