Deep-dive reference covering every hook event, configuration format, payload schemas, output protocol, environment variables, the status line, and practical implementation patterns.
- What Are Hooks?
- Hook Lifecycle Overview
- Settings File Configuration
- Complete Hook Event Reference
- Hook Input Payload Format
- Hook Output Format
- Exit Codes
- Matcher Syntax
- Hook Handler Types
- Environment Variables
- Status Line Configuration
- Writing Hook Scripts
- Platform Considerations
- Hook Execution Order and Timing
- Performance Impact
- Common Patterns
- Hooks and Agent Teams
- Gotchas and Sharp Edges
- Testing Hooks
- The /hooks Menu
- Security Considerations
Hooks are user-defined shell commands, HTTP endpoints, LLM prompts, or spawned agents that Claude Code executes automatically at specific points in its lifecycle. They provide deterministic control over Claude Code's behavior — ensuring actions always happen (or are blocked) rather than relying on the model to decide.
Core capabilities:
- Block operations before they execute (pre-validation)
- React to completed operations (post-processing, formatting, logging)
- Modify tool inputs before execution (input sanitization, path rewriting)
- Inject context into Claude's conversation (system messages, additional instructions)
- Send notifications to external systems (Slack, webhooks, desktop alerts)
- Control agent team behavior (prevent idle, prevent task completion)
Hooks run locally and do not consume API tokens (except prompt/agent type hooks which make LLM calls).
SessionStart
|
UserPromptSubmit
|
+---> [Agentic Loop] ----------------------+
| | |
| PreToolUse |
| PermissionRequest |
| PostToolUse / PostToolUseFailure |
| SubagentStart / SubagentStop |
| TeammateIdle |
| TaskCompleted |
| PreCompact / PostCompact |
| InstructionsLoaded |
| ConfigChange |
| Notification |
| Elicitation / ElicitationResult |
| WorktreeCreate / WorktreeRemove |
+------------------------------------------+
|
Stop / StopFailure
|
SessionEnd
| File | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All projects (global) | No — local to your machine |
.claude/settings.json |
Single project | Yes — commit to repo |
.claude/settings.local.json |
Single project | No — gitignored |
| Managed policy settings | Organization-wide | Yes — admin-controlled |
Plugin hooks/hooks.json |
When plugin is enabled | Yes — bundled with plugin |
| Skill/Agent frontmatter | Component lifetime | Yes — in component file |
{
"hooks": {
"EventName": [
{
"matcher": "regex_pattern",
"hooks": [
{
"type": "command",
"command": "/path/to/script.sh",
"timeout": 600,
"statusMessage": "Custom spinner message",
"once": false,
"async": false
}
]
}
]
},
"disableAllHooks": false,
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"padding": 0
}
}| Field | Description |
|---|---|
hooks |
Object keyed by event name, value is array of matcher objects |
disableAllHooks |
Set true to disable all hooks globally (also disables the status line) |
statusLine |
Separate top-level key — not inside hooks (see Section 11) |
| Field | Required | Description |
|---|---|---|
matcher |
No | Regex pattern filtering when the hook fires. Omit to match every occurrence. |
hooks |
Yes | Array of hook handler objects |
| Field | Required | Default | Description |
|---|---|---|---|
type |
Yes | — | "command", "http", "prompt", or "agent" |
timeout |
No | Varies | Seconds before cancelling. Command: 600, HTTP: 30, Prompt: 30, Agent: 60 |
statusMessage |
No | — | Custom spinner text shown while hook runs |
once |
No | false |
If true, runs only once per session then removes itself (skills only) |
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "bash hooks/bash-guard.sh" },
{ "type": "command", "command": "bash hooks/enforce-uv.sh" }
]
},
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "bash hooks/protect-secrets.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
},
{ "type": "command", "command": "bash hooks/audit-log.sh" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "bash hooks/notify-done.sh" }
]
}
]
}
}| Event | When It Fires | Supports Matcher | Can Block? |
|---|---|---|---|
SessionStart |
Session begins or resumes | Yes | No |
UserPromptSubmit |
User submits a prompt, before Claude processes it | No | Yes |
PreToolUse |
Before a tool call executes | Yes (tool name) | Yes |
PermissionRequest |
When a permission dialog would appear | Yes (tool name) | Yes |
PostToolUse |
After a tool call succeeds | Yes (tool name) | No (shows feedback to Claude) |
PostToolUseFailure |
After a tool call fails | Yes (tool name) | No |
Notification |
When Claude Code sends a notification | Yes (notification type) | No |
SubagentStart |
When a subagent is spawned | Yes (agent type) | No |
SubagentStop |
When a subagent finishes | Yes (agent type) | Yes |
Stop |
When Claude finishes responding | No | Yes |
StopFailure |
When turn ends due to API error | Yes (error type) | No — output ignored |
TeammateIdle |
When an agent team teammate is about to go idle | No | Yes |
TaskCompleted |
When a task is marked as complete | No | Yes |
InstructionsLoaded |
When CLAUDE.md or rules file is loaded | Yes (load reason) | No |
ConfigChange |
When a configuration file changes during session | Yes (config source) | Yes |
WorktreeCreate |
When a worktree is being created | No | Yes (failure = exit 2) |
WorktreeRemove |
When a worktree is being removed | No | No |
PreCompact |
Before context compaction | Yes (manual/auto) |
No |
PostCompact |
After context compaction completes | Yes (manual/auto) |
No |
Elicitation |
When MCP server requests user input | Yes (MCP server name) | Yes |
ElicitationResult |
After user responds to MCP elicitation | Yes (MCP server name) | Yes |
SessionEnd |
When session terminates | Yes (exit reason) | No |
Fires when a session begins or resumes. Use for context injection, environment setup, and re-loading critical information after compaction.
Matcher values: startup, resume, clear, compact
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "SessionStart",
"source": "startup",
"model": "claude-sonnet-4-6",
"agent_type": "optional"
}Special capability: Write to $CLAUDE_ENV_FILE to persist environment variables:
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"Output: additionalContext field in hookSpecificOutput. Any stdout is added to Claude's context.
Exit code 2: Does not block — shows stderr to user only.
Example — re-inject context after compaction:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}Fires when the user submits a prompt, before Claude processes it. Can block the prompt entirely.
Matcher: Not supported — always fires.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "UserPromptSubmit",
"prompt": "The user's prompt text"
}Output: Use additionalContext to inject text into Claude's context, or decision: "block" to reject the prompt and erase it.
Exit code 2: Blocks prompt and erases the user's input.
Fires before a tool call executes. The most commonly used hook for validation and access control.
Matcher: Tool name (regex)
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test"
},
"tool_use_id": "toolu_abc123"
}Tool-specific tool_input shapes:
// Write tool
{ "file_path": "src/config.py", "content": "..." }
// Edit tool
{ "file_path": "src/app.py", "old_string": "...", "new_string": "..." }
// Bash tool
{ "command": "rm -rf dist/" }
// Read tool
{ "file_path": "README.md" }Decision output (hookSpecificOutput):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Reason shown to Claude",
"updatedInput": { "command": "safer command" },
"additionalContext": "Extra context for Claude"
}
}"allow"— skips the interactive permission prompt (deny rules still apply)"deny"— cancels tool call, sends reason to Claude"ask"— shows permission prompt to user
Note on updatedInput: When modifying tool input, you must also return permissionDecision: "allow". Return a new object, do not mutate.
Exit code 2: Blocks the tool call. Stderr is shown to Claude as feedback.
Fires when a permission dialog is about to appear, allowing programmatic approval or denial.
Matcher: Tool name (regex)
Note: Does not fire in non-interactive/headless mode (-p flag). Use PreToolUse for automated permission decisions in headless contexts.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PermissionRequest",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf /" },
"permission_suggestions": [...]
}Decision output:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": { "command": "safe command" },
"updatedPermissions": [
{
"type": "addRules|replaceRules|removeRules|setMode|addDirectories|removeDirectories",
"rules": [{ "toolName": "Bash", "ruleContent": "npm run" }],
"behavior": "allow|deny|ask",
"mode": "default|acceptEdits|dontAsk|bypassPermissions|plan",
"directories": ["/path"],
"destination": "session|localSettings|projectSettings|userSettings"
}
],
"message": "Reason shown for deny",
"interrupt": false
}
}
}Example — auto-approve ExitPlanMode:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}Fires after a tool call succeeds. Used for formatting, linting, logging, and audit trails.
Important: Cannot undo the action — the tool has already executed.
Matcher: Tool name (regex)
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { "file_path": "/path/to/file.txt" },
"tool_response": { "success": true },
"tool_use_id": "toolu_abc123"
}Decision output:
{
"decision": "block",
"reason": "ESLint errors found",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Context appended to tool result",
"updatedMCPToolOutput": "Modified MCP tool output"
}
}Exit code 2: Does NOT block — shows stderr to Claude as feedback so it can adjust. Claude sees the message and can attempt corrections.
Fires when a tool execution fails.
Matcher: Tool name (regex)
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PostToolUseFailure",
"tool_name": "Bash",
"tool_input": { "command": "npm test" },
"tool_use_id": "toolu_abc123",
"error": "Command failed with exit code 1",
"is_interrupt": false
}Output: additionalContext — cannot block.
Fires when Claude Code sends a notification. Output and exit code are ignored for blocking purposes.
Matcher values: permission_prompt, idle_prompt, auth_success, elicitation_dialog
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "Notification",
"message": "Notification text",
"title": "Optional title",
"notification_type": "permission_prompt"
}Example — desktop notification (platform-specific):
macOS:
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}Linux:
{
"type": "command",
"command": "notify-send 'Claude Code' 'Claude Code needs your attention'"
}Windows (PowerShell):
{
"type": "command",
"command": "powershell.exe -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude Code needs your attention', 'Claude Code')\""
}Fires when a subagent is spawned. Can inject additional context.
Matcher: Agent type
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "SubagentStart",
"agent_id": "agent-abc123",
"agent_type": "Explore"
}Output: additionalContext. Exit code 2 shows stderr to user only — does not block.
Fires when a subagent finishes. Can block the subagent from stopping (making it continue).
Matcher: Agent type
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "SubagentStop",
"agent_id": "def456",
"agent_type": "Explore",
"agent_transcript_path": "path/to/transcript",
"last_assistant_message": "Final response",
"stop_hook_active": false
}Decision output:
{
"decision": "block",
"reason": "Tasks not yet complete"
}Exit code 2: Prevents subagent from stopping, feeds stderr to subagent as continuation instruction.
Important: Check stop_hook_active to prevent infinite loops. For subagents, Stop hooks are automatically converted to SubagentStop.
Fires when the main Claude agent finishes responding. Does not fire on user interrupts; API errors fire StopFailure instead.
Matcher: Not supported — always fires.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "Stop",
"stop_hook_active": false,
"last_assistant_message": "Claude's final response text"
}Decision output:
{
"decision": "block",
"reason": "Tests are still failing — please fix them"
}Exit code 2: Prevents Claude from stopping, continues the conversation. Claude receives stderr as its next instruction.
Critical: Always check stop_hook_active before blocking, or you will create an infinite loop:
#!/bin/bash
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Allow Claude to stop
fi
# ... your logicFires when a turn ends due to an API error. Output and exit codes are ignored.
Matcher values: rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "StopFailure",
"error": "rate_limit",
"error_details": "Detailed error message",
"last_assistant_message": "Error text"
}Fires when an agent team teammate is about to go idle.
Matcher: Not supported — always fires.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "TeammateIdle",
"teammate_name": "researcher",
"team_name": "my-team"
}Control: Exit code 2 prevents teammate from going idle (feeds stderr as new task). Or return JSON:
{
"continue": false,
"stopReason": "Work is complete"
}Fires when a task is being marked as completed. Can prevent task completion.
Matcher: Not supported — always fires.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "TaskCompleted",
"task_id": "task-001",
"task_subject": "Implement feature",
"task_description": "Detailed description",
"teammate_name": "optional",
"team_name": "optional"
}Control: Exit code 2 prevents task completion, feeds stderr as feedback. Or return JSON:
{
"continue": false,
"stopReason": "Task criteria not met"
}Fires when a CLAUDE.md or .claude/rules/*.md file is loaded. No decision control.
Matcher values: session_start, nested_traversal, path_glob_match, include, compact
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "InstructionsLoaded",
"file_path": "/path/to/CLAUDE.md",
"memory_type": "User|Project|Local|Managed",
"load_reason": "session_start",
"globs": ["optional", "patterns"],
"trigger_file_path": "optional",
"parent_file_path": "optional"
}Fires when a configuration file changes during a session.
Matcher values: user_settings, project_settings, local_settings, policy_settings, skills
Note: Cannot block policy_settings changes.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "ConfigChange",
"source": "project_settings",
"file_path": "/path/to/.claude/settings.json"
}Decision output:
{
"decision": "block",
"reason": "Unauthorized configuration change"
}Fires when a worktree is being created. The hook's stdout must contain the absolute path to the worktree directory, replacing the default git behavior.
Matcher: Not supported.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "WorktreeCreate",
"name": "feature-auth"
}Required output: Print absolute path to stdout:
#!/bin/bash
NAME=$(jq -r '.name')
DIR="/path/to/worktree/$NAME"
git worktree add "$DIR" >&2
echo "$DIR"Exit code 2: Causes worktree creation to fail.
Fires when a worktree is being removed. No decision control; failures logged in debug only.
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "WorktreeRemove",
"worktree_path": "/path/to/worktree"
}Fires before context compaction. Useful for archiving the full transcript before summarization.
Matcher values: manual, auto
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PreCompact",
"trigger": "manual|auto",
"custom_instructions": "user instructions"
}Fires after context compaction completes.
Matcher values: manual, auto
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "PostCompact",
"trigger": "manual|auto",
"compact_summary": "Generated summary text"
}Fires when an MCP server requests user input during a tool call.
Matcher: MCP server name
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "Elicitation",
"mcp_server_name": "my-server",
"message": "Please provide input",
"mode": "form|url",
"requested_schema": { "type": "object" },
"url": "optional_auth_url",
"elicitation_id": "optional_id"
}Decision output:
{
"hookSpecificOutput": {
"hookEventName": "Elicitation",
"action": "accept|decline|cancel",
"content": { "field": "value" }
}
}Fires after a user responds to an MCP elicitation, before the response is sent back to the server.
Matcher: MCP server name
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "ElicitationResult",
"mcp_server_name": "my-server",
"message": "Response message",
"mode": "form|url",
"elicitation_id": "id",
"user_response": { "field": "value" }
}Decision output: Same as Elicitation.
Fires when the session terminates. Default timeout is 1.5 seconds (configurable via environment variable).
Matcher values: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other
Input payload:
{
"session_id": "abc123",
"cwd": "/path/to/project",
"hook_event_name": "SessionEnd",
"reason": "clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other"
}Note: Cannot block. Use CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS to override the default 1.5-second timeout.
Every hook receives these fields in its JSON stdin payload:
| Field | Type | Description |
|---|---|---|
session_id |
string | Unique identifier for this session |
transcript_path |
string | Path to the conversation JSON transcript |
cwd |
string | Current working directory when the hook fires |
permission_mode |
string | Current permission mode: "default", "plan", "acceptEdits", "dontAsk", or "bypassPermissions" |
hook_event_name |
string | Name of the event that triggered this hook |
When a hook fires inside a subagent:
| Field | Type | Description |
|---|---|---|
agent_id |
string | Unique identifier for the subagent |
agent_type |
string | Agent name (e.g., "Explore", "security-reviewer") |
#!/bin/bash
INPUT=$(cat) # Read all of stdin
# Extract individual fields
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id')
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')There are five distinct output patterns depending on the event type:
Events: UserPromptSubmit, PostToolUse, PostToolUseFailure, Stop, SubagentStop, ConfigChange
{
"decision": "block",
"reason": "Explanation for blocking"
}{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Reason shown to Claude",
"updatedInput": { "modified": "tool input" },
"additionalContext": "Extra context for Claude"
}
}{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": { "modified": "input" },
"updatedPermissions": [],
"message": "Reason for deny",
"interrupt": false
}
}
}{
"continue": false,
"stopReason": "Explanation shown to user"
}{
"hookSpecificOutput": {
"hookEventName": "Elicitation",
"action": "accept|decline|cancel",
"content": { "field": "value" }
}
}Print the absolute path to the worktree directory on stdout:
echo "/absolute/path/to/worktree"These fields work for all events that produce JSON output:
| Field | Type | Default | Description |
|---|---|---|---|
continue |
boolean | true |
If false, Claude stops processing entirely |
stopReason |
string | — | Shown to user when continue: false |
suppressOutput |
boolean | false |
If true, hides stdout from verbose mode |
systemMessage |
string | — | Warning shown to the user |
hookSpecificOutput |
object | — | Event-specific fields (see patterns above) |
When multiple hooks or permission rules apply: deny > ask > allow. If any hook returns deny, the operation is blocked regardless of other hooks.
| Exit Code | Meaning | JSON Processing | Stderr Handling |
|---|---|---|---|
0 |
Success | Parse stdout as JSON | Shown in verbose mode only |
2 |
Blocking error | Ignore JSON | Fed to Claude as feedback (or user, depending on event) |
| Any other | Non-blocking error | Ignore JSON | Shown in verbose mode only |
| Event | Exit 2 Effect |
|---|---|
PreToolUse |
Blocks tool call; stderr shown to Claude |
PermissionRequest |
Denies permission; stderr shown to Claude |
UserPromptSubmit |
Blocks prompt; erases user input |
Stop |
Prevents stopping; stderr becomes Claude's next instruction |
SubagentStop |
Prevents subagent from stopping |
TeammateIdle |
Prevents teammate from going idle; stderr becomes new task |
TaskCompleted |
Prevents task completion; stderr as feedback |
ConfigChange |
Blocks config change (except policy_settings) |
Elicitation |
Denies elicitation |
ElicitationResult |
Blocks response (becomes decline) |
WorktreeCreate |
Causes creation to fail |
PostToolUse |
Shows stderr to Claude — does NOT block |
PostToolUseFailure |
Shows stderr to Claude — does NOT block |
Notification |
Shows stderr to user only |
SubagentStart |
Shows stderr to user only |
SessionStart |
Shows stderr to user only |
SessionEnd |
Output ignored |
StopFailure |
Output ignored entirely |
Critical rule: Only exit code 2 produces blocking behavior. An exit code of 1 (uncaught exception, script error) will allow the tool call to proceed silently.
Matchers are regex patterns tested against a specific field depending on the event type.
| Event | Matcher Filters | Example Values |
|---|---|---|
PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest |
Tool name | Bash, Edit|Write, mcp__.*, ^mcp__github__ |
SessionStart |
Session source | startup, resume, clear, compact |
SessionEnd |
Exit reason | clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other |
Notification |
Notification type | permission_prompt, idle_prompt, auth_success, elicitation_dialog |
SubagentStart, SubagentStop |
Agent type | Bash, Explore, Plan, custom names |
PreCompact, PostCompact |
Compaction trigger | manual, auto |
ConfigChange |
Config source | user_settings, project_settings, local_settings, policy_settings, skills |
StopFailure |
Error type | rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown |
InstructionsLoaded |
Load reason | session_start, nested_traversal, path_glob_match, include, compact |
Elicitation, ElicitationResult |
MCP server name | Configured server names |
UserPromptSubmit, Stop, TeammateIdle, TaskCompleted, WorktreeCreate, WorktreeRemove |
No matcher support | Always fires |
"*" — Matches all (wildcard; treated as a regex matching everything)
"Write" — Exact match
"Edit|Write" — Either Edit or Write (regex OR)
"mcp__.*" — All MCP tools
"^mcp__github__" — All GitHub MCP tools
"mcp__.*__write.*" — All write operations across any MCP server
MCP tools follow the pattern mcp__<server>__<tool>:
mcp__memory__create_entities
mcp__filesystem__read_file
mcp__github__search_repositories
mcp__playwright__browser_screenshot
The <server> name comes from the key used in the mcpServers configuration. Matchers are case-sensitive.
Matchers only filter by tool name, not by file paths, command content, or other arguments. To filter by file path, check tool_input.file_path inside your hook script:
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE" != *.md ]]; then exit 0; fi # Skip non-markdown filesRuns a shell command with JSON input via stdin.
{
"type": "command",
"command": ".claude/hooks/my-script.sh",
"timeout": 600,
"async": false,
"statusMessage": "Validating..."
}| Field | Required | Default | Description |
|---|---|---|---|
command |
Yes | — | Shell command to execute |
timeout |
No | 600 | Seconds before cancelling |
async |
No | false |
If true, runs in background without blocking |
statusMessage |
No | — | Custom spinner text |
Async mode: When async: true, Claude does not wait for the hook to complete. Use for side effects (logging, webhooks) that should not block execution. Async hooks cannot block operations or provide decision output.
Sends a JSON POST request to an endpoint.
{
"type": "http",
"url": "http://localhost:8080/hooks/tool-use",
"timeout": 30,
"headers": {
"Authorization": "Bearer $MY_TOKEN"
},
"allowedEnvVars": ["MY_TOKEN"]
}| Field | Required | Default | Description |
|---|---|---|---|
url |
Yes | — | URL to POST to |
headers |
No | — | HTTP headers; values support $VAR_NAME interpolation |
allowedEnvVars |
No | — | Env vars permitted for interpolation |
timeout |
No | 30 | Seconds before cancelling |
HTTP response handling:
- 2xx with empty body → success (equivalent to exit 0)
- 2xx with plain text → success + text added as context
- 2xx with JSON → success + parsed as standard output
- Non-2xx → non-blocking error, execution continues
- Connection failure/timeout → non-blocking error
Note: HTTP status codes alone cannot block actions. To block, return 2xx with appropriate hookSpecificOutput fields.
Single-turn LLM evaluation. Uses a fast model (Haiku) by default.
{
"type": "prompt",
"prompt": "Is this shell command safe? $ARGUMENTS. Respond: {\"ok\": true} or {\"ok\": false, \"reason\": \"explanation\"}",
"model": "claude-haiku",
"timeout": 30
}| Field | Required | Description |
|---|---|---|
prompt |
Yes | Prompt text. Use $ARGUMENTS as placeholder for input JSON |
model |
No | Model to use (defaults to fast model) |
Output format: Model must return {"ok": true} or {"ok": false, "reason": "..."}. If ok: false, the reason is fed back to Claude and the action is blocked.
Use case: Decisions requiring judgment (is this command safe?), not deterministic pattern matching.
Spawns a subagent with tool access for multi-turn verification.
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results. $ARGUMENTS",
"model": "claude-sonnet",
"timeout": 120
}| Field | Required | Default | Description |
|---|---|---|---|
prompt |
Yes | — | Prompt for the verification agent |
model |
No | — | Model to use |
timeout |
No | 60 | Seconds (up to 50 tool-use turns) |
Use case: Verification requiring codebase inspection (does the code compile? do tests pass?). Use prompt hooks when input data alone is sufficient; use agent hooks when you need to inspect actual codebase state.
| Variable | Description |
|---|---|
$CLAUDE_PROJECT_DIR |
Project root directory |
$CLAUDE_CODE_REMOTE |
Set to "true" in web environments; not set in local CLI |
${CLAUDE_PLUGIN_ROOT} |
Plugin installation directory (plugin hooks only) |
${CLAUDE_PLUGIN_DATA} |
Plugin persistent data directory (plugin hooks only) |
| Variable | Description |
|---|---|
$CLAUDE_ENV_FILE |
Path to a file where you can write export VAR=value statements to persist environment variables across the session |
Usage:
#!/bin/bash
# Run on SessionStart to set project-specific environment
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export DATABASE_URL=postgresql://localhost/myapp' >> "$CLAUDE_ENV_FILE"| Variable | Description |
|---|---|
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS |
Override the default 1.5-second timeout for SessionEnd hooks |
Usage:
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=5000 claudeThese are accessed by parsing the JSON stdin payload, not as shell environment variables:
session_id— unique session identifiercwd— current working directorytool_name— name of the tool being calledtool_input.file_path— file path for file operationstool_input.command— command for Bash tool
The status line is a separate feature from hooks — configured under a top-level statusLine key, not inside hooks. It provides a persistent, customizable bar at the bottom of Claude Code.
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"padding": 0
}
}| Field | Required | Default | Description |
|---|---|---|---|
type |
Yes | — | Always "command" |
command |
Yes | — | Shell command or path to script |
padding |
No | 0 |
Extra horizontal spacing characters |
Note: If disableAllHooks: true is set in settings, the status line is also disabled.
/statusline show model name and context percentage with a progress bar
This generates the script and updates settings automatically.
- Claude Code pipes JSON session data to your script via stdin
- Your script extracts what it needs and prints text to stdout
- Claude Code displays whatever your script prints at the bottom of the interface
Update timing: Runs after each new assistant message, on permission mode change, and on vim mode toggle. Updates are debounced at 300ms. If a new update triggers while the script is running, the in-flight execution is cancelled.
Does not consume API tokens. Temporarily hides during autocomplete suggestions, the help menu, and permission prompts.
{
"cwd": "/current/working/directory",
"session_id": "abc123...",
"transcript_path": "/path/to/transcript.jsonl",
"model": {
"id": "claude-opus-4-6",
"display_name": "Opus"
},
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/original/project/directory"
},
"version": "1.0.80",
"output_style": {
"name": "default"
},
"cost": {
"total_cost_usd": 0.01234,
"total_duration_ms": 45000,
"total_api_duration_ms": 2300,
"total_lines_added": 156,
"total_lines_removed": 23
},
"context_window": {
"total_input_tokens": 15234,
"total_output_tokens": 4521,
"context_window_size": 200000,
"used_percentage": 8,
"remaining_percentage": 92,
"current_usage": {
"input_tokens": 8500,
"output_tokens": 1200,
"cache_creation_input_tokens": 5000,
"cache_read_input_tokens": 2000
}
},
"exceeds_200k_tokens": false,
"rate_limits": {
"five_hour": {
"used_percentage": 23.5,
"resets_at": 1738425600
},
"seven_day": {
"used_percentage": 41.2,
"resets_at": 1738857600
}
},
"vim": { "mode": "NORMAL" },
"agent": { "name": "security-reviewer" },
"worktree": {
"name": "my-feature",
"path": "/path/to/.claude/worktrees/my-feature",
"branch": "worktree-my-feature",
"original_cwd": "/path/to/project",
"original_branch": "main"
}
}| Field | Description |
|---|---|
model.id, model.display_name |
Current model |
cwd, workspace.current_dir |
Current working directory (identical) |
workspace.project_dir |
Directory where Claude Code was launched |
cost.total_cost_usd |
Total session cost in USD |
cost.total_duration_ms |
Total wall-clock time (ms) |
cost.total_api_duration_ms |
Time spent waiting for API (ms) |
cost.total_lines_added, cost.total_lines_removed |
Lines changed |
context_window.context_window_size |
Max tokens (200K or 1M) |
context_window.used_percentage |
Pre-calculated % of context used |
context_window.remaining_percentage |
Pre-calculated % remaining |
context_window.current_usage |
Token counts from last API call (null before first call) |
exceeds_200k_tokens |
Whether last response exceeded 200K tokens |
rate_limits.five_hour.used_percentage |
% of 5-hour rate limit used (Pro/Max only) |
rate_limits.seven_day.used_percentage |
% of 7-day rate limit used (Pro/Max only) |
rate_limits.*.resets_at |
Unix epoch when rate limit resets |
session_id |
Unique session identifier |
version |
Claude Code version |
vim.mode |
NORMAL or INSERT (only when vim mode enabled) |
agent.name |
Agent name (only when using --agent flag) |
worktree.* |
Worktree data (only during --worktree sessions) |
Context window used_percentage formula: (input_tokens + cache_creation_input_tokens + cache_read_input_tokens) / context_window_size. Does not include output tokens.
Minimal inline command:
{
"statusLine": {
"type": "command",
"command": "jq -r '\"[\\(.model.display_name)] \\(.context_window.used_percentage // 0)% context\"'"
}
}Full script with progress bar (Bash):
#!/bin/bash
input=$(cat)
MODEL=$(echo "$input" | jq -r '.model.display_name')
PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
COST=$(echo "$input" | jq -r '.cost.total_cost_usd // 0')
BAR_WIDTH=10
FILLED=$((PCT * BAR_WIDTH / 100))
EMPTY=$((BAR_WIDTH - FILLED))
BAR=""
[ "$FILLED" -gt 0 ] && printf -v FILL "%${FILLED}s" && BAR="${FILL// /▓}"
[ "$EMPTY" -gt 0 ] && printf -v PAD "%${EMPTY}s" && BAR="${BAR}${PAD// /░}"
COST_FMT=$(printf '$%.2f' "$COST")
echo "[$MODEL] $BAR $PCT% | $COST_FMT"Windows (PowerShell):
{
"statusLine": {
"type": "command",
"command": "powershell -NoProfile -File C:/Users/username/.claude/statusline.ps1"
}
}Cache expensive operations (status line runs frequently — cache git calls):
CACHE_FILE="/tmp/statusline-git-cache"
CACHE_MAX_AGE=5 # seconds
if [ ! -f "$CACHE_FILE" ] || [ $(($(date +%s) - $(stat -f %m "$CACHE_FILE" 2>/dev/null || stat -c %Y "$CACHE_FILE"))) -gt $CACHE_MAX_AGE ]; then
git branch --show-current 2>/dev/null > "$CACHE_FILE"
fi
BRANCH=$(cat "$CACHE_FILE")Use a fixed filename for the cache (not $$ or process-based names) since each status line invocation runs as a new process.
#!/bin/bash
# Read the full JSON input from stdin
INPUT=$(cat)
# Extract fields using jq
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
# Guard: skip if no relevant data
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# Your logic here...
# Communicate back to Claude via stderr (for blocking)
echo "Error message for Claude" >&2
exit 2 # Block the action
# OR allow with context via stdout JSON
echo '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": "Extra info"}}'
exit 0#!/usr/bin/env node
let input = '';
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
const data = JSON.parse(input);
const toolName = data.tool_name;
const filePath = data.tool_input?.file_path;
// Your logic here
if (shouldBlock) {
process.stderr.write('Blocked: reason for Claude\n');
process.exit(2);
}
// Return JSON for structured output
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow'
}
}));
process.exit(0);
});#!/usr/bin/env python3
import json
import sys
data = json.load(sys.stdin)
tool_name = data.get('tool_name', '')
file_path = data.get('tool_input', {}).get('file_path', '')
# Your logic here
if should_block:
print("Blocked: reason for Claude", file=sys.stderr)
sys.exit(2)
# Return JSON for structured output
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
}
print(json.dumps(output))
sys.exit(0)chmod +x ~/.claude/hooks/my-hook.shWindows scripts do not need the executable bit but must be referenced with the appropriate interpreter in the command field.
- Scripts run in a non-interactive shell sourcing
~/.zshrcor~/.bashrc - Watch out: Shell profile files that print output unconditionally will corrupt JSON parsing (see Gotcha 8)
- Wrap profile echoes in
if [[ $- == *i* ]]; then ... fi(checks for interactive shell) - Make scripts executable with
chmod +x - Use
#!/bin/bashor#!/usr/bin/env python3shebangs
- Claude Code runs hook commands through Git Bash on Windows
- You can invoke PowerShell from Git Bash:
powershell.exe -Command "..." - Or reference a
.ps1script:powershell -NoProfile -File C:/path/to/script.ps1 - Forward slashes work in paths within hook configurations
- No
chmod +xneeded; Windows uses extension-based execution jqmay need separate installation on Windows (via Chocolatey, Scoop, or winget)- Environment variable syntax in
commandstrings: use$VAR(Git Bash syntax), not%VAR%
Windows Notification Example:
{
"type": "command",
"command": "powershell.exe -Command \"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); [System.Windows.Forms.MessageBox]::Show('Claude finished', 'Claude Code')\""
}Use $CLAUDE_PROJECT_DIR for portable script references:
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/my-hook.sh"
}Normalize paths in scripts to handle both absolute and relative values:
file_path=$(realpath "$file_path" 2>/dev/null || echo "$file_path")- Hooks within a single matcher's
hooksarray run sequentially in order - Multiple matchers for the same event run in the order they appear in the configuration
- Hooks from different settings files are merged; policy settings take highest priority
- Identical hook commands (same command string or URL) are deduplicated — run only once per event
PreToolUsehooks from the first blocking hook short-circuit remaining hooks for that event
When multiple matchers fire for the same event, all matching hooks from different matchers run in parallel. Within a single matcher's hooks array, they run sequentially.
PreToolUsehooks run synchronously before the tool executesPostToolUsehooks run synchronously after the tool returnsStophooks run before Claude's turn endsSessionEndhooks have a default 1.5-second timeout (override withCLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS)- Status line updates are debounced at 300ms
Event fires
→ Collect all registered hooks matching this event + matcher
→ Deduplicate identical handlers
→ Execute hooks (parallel across matchers, sequential within a matcher)
→ Collect exit codes and output
→ Apply decisions (deny priority > ask > allow)
→ Proceed or block
| Hook Type | Performance Risk | Mitigation |
|---|---|---|
PreToolUse (all tools) |
High — runs on every tool call | Use narrow matchers; keep logic fast |
PostToolUse (all tools) |
High — runs on every tool call | Use narrow matchers; async for logging |
Stop |
Low — runs once per turn | Safe for heavier checks |
SessionStart |
Low — runs once per session | Fine for initialization |
StatusLine |
Medium — runs on every message | Cache slow operations (git, file reads) |
-
Keep
PreToolUseandPostToolUsehooks fast — they run on every matching tool call. A hook that takes 2 seconds on every file edit means every Claude edit takes 2 extra seconds. -
Move expensive checks to
Stophooks — after Claude finishes its turn is the right time to run full test suites, linters on all changed files, etc. -
Use narrow matchers —
matcher: "Edit|Write"is far better thanmatcher: "*"if you only care about file edits. -
Use async for side effects — logging, webhook calls, and notifications that don't need to influence behavior should use
async: trueso they don't block execution. -
Cache slow external calls in status line scripts:
# Use a fixed cache file path — not process-based names CACHE="/tmp/statusline-git-$CLAUDE_PROJECT_DIR_HASH"
-
Avoid running full test suites on
PreToolUse— this makes Claude extremely slow. Run tests onStopinstead.
Prevent Claude from modifying sensitive files:
#!/bin/bash
# hooks/protect-secrets.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE" ]] && exit 0 # Guard: no file path, allow
PROTECTED_PATTERNS=(".env" ".env.*" "secrets.*" "credentials.*" "*.pem" "*.key" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$(basename "$FILE")" == $pattern || "$FILE" == *".git/"* ]]; then
echo "Blocked: $FILE matches a protected pattern." >&2
exit 2
fi
done
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-secrets.sh" }]
}
]
}
}{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}#!/bin/bash
# hooks/bash-guard.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
DANGEROUS_PATTERNS=(
"rm -rf /"
"rm -rf ~"
"dd if="
"> /dev/sda"
"mkfs"
"chmod -R 777 /"
"curl.*| bash"
"wget.*| bash"
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$CMD" | grep -qiE "$pattern"; then
echo "Blocked: Command matches dangerous pattern '$pattern'" >&2
echo "Command was: $CMD" >&2
exit 2
fi
done
exit 0#!/bin/bash
# hooks/enforce-uv.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE "^pip install|^pip3 install"; then
echo "Blocked: Use 'uv add' instead of 'pip install' in this project." >&2
exit 2
fi
exit 0#!/bin/bash
# hooks/audit-log.sh
INPUT=$(cat)
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
SESSION=$(echo "$INPUT" | jq -r '.session_id')
echo "[$TIMESTAMP] Tool: $TOOL | Session: $SESSION" >> ~/.claude/audit.log
echo "$INPUT" >> ~/.claude/audit.log
echo "---" >> ~/.claude/audit.log
exit 0 # Never block — just log{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "bash hooks/audit-log.sh", "async": true }]
}
]
}
}#!/bin/bash
# hooks/notify-done.sh (macOS)
osascript -e 'display notification "Claude Code finished its task." with title "Claude Code"'{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "bash hooks/notify-done.sh" }]
}
]
}
}#!/bin/bash
# hooks/run-tests.sh
INPUT=$(cat)
# Prevent infinite loop
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
echo "Running tests..."
if pytest tests/ -q 2>&1; then
echo "All tests passed."
exit 0
else
echo "Tests failed — Claude should fix the failures." >&2
exit 2 # Keep Claude working
fi{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the conversation. Have all requested tasks been completed? If any are incomplete, respond {\"ok\": false, \"reason\": \"describe what remains\"}. If complete, respond {\"ok\": true}."
}
]
}
]
}
}{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "bash -c 'echo \"Current git status:\"; git log --oneline -5 2>/dev/null; echo \"Active issues:\"; cat .claude/context.md 2>/dev/null || true'"
}
]
}
]
}
}#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILE" ]]; then
SANDBOXED="/sandbox$FILE"
# Return modified input with allow decision
jq -n \
--arg path "$SANDBOXED" \
'{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: {file_path: $path}
}
}'
exit 0
fi
exit 0Used with Claude Code's agent team feature. Fires when a teammate agent is about to go idle, allowing you to assign new work or keep the team productive.
#!/bin/bash
INPUT=$(cat)
TEAMMATE=$(echo "$INPUT" | jq -r '.teammate_name')
TEAM=$(echo "$INPUT" | jq -r '.team_name')
# Assign remaining tasks to idle teammates
echo "Teammate $TEAMMATE in team $TEAM is idle. Check task queue for pending work." >&2
exit 2 # Prevent going idle — send it the feedback messageFires when a teammate marks a task as complete. Use for verification, QA, or aggregating results.
#!/bin/bash
INPUT=$(cat)
TASK_ID=$(echo "$INPUT" | jq -r '.task_id')
SUBJECT=$(echo "$INPUT" | jq -r '.task_subject')
# Log completed task
echo "Task completed: [$TASK_ID] $SUBJECT" >> ~/.claude/task-log.txt
# Optionally prevent completion if criteria not met
# echo "Task not verified yet" >&2
# exit 2
exit 0Fires when any subagent completes. Use for aggregating parallel results.
#!/bin/bash
INPUT=$(cat)
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id')
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type')
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message')
TRANSCRIPT=$(echo "$INPUT" | jq -r '.agent_transcript_path')
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
echo "[SUBAGENT DONE] $AGENT_TYPE ($AGENT_ID)" >> ~/.claude/subagent-log.txt
echo "Last message: $LAST_MSG" >> ~/.claude/subagent-log.txt
exit 0A UserPromptSubmit hook that spawns subagents can create infinite loops if those subagents trigger the same hook. Mitigation:
INPUT=$(cat)
AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty')
# Skip if already inside a subagent
if [[ -n "$AGENT_ID" ]]; then
exit 0
fiSubagents do not automatically inherit parent agent permissions. To avoid repeated permission prompts, use PreToolUse hooks to auto-approve specific tools across all sessions, or configure permission rules with destination: "session".
An exit code of 1 (uncaught exception, script error, missing binary) allows the action to proceed. Your hook may fail silently. Always test scripts manually before deploying.
# Exit 1 = allow (but warns in verbose mode)
# Exit 2 = block (stderr shown to Claude)
# Exit 0 = allow (parses JSON stdout)If your Stop hook exits with code 2, Claude continues. If it does so unconditionally, Claude loops forever. Always check stop_hook_active:
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Break the loop — let Claude stop
fiPostToolUse with exit code 2 does NOT block — it shows stderr to Claude as feedback. The tool has already executed. If you want to prevent an action, use PreToolUse.
If a PostToolUse hook triggers formatting (Write), and that Write triggers another PostToolUse hook, you can create a loop. Make hooks idempotent:
# Track if we're already in a formatting pass
if [[ "$FILE" == *".formatted"* ]]; then exit 0; fiWhen Claude Code runs a hook, it spawns a non-interactive shell that may source ~/.zshrc / ~/.bashrc. If those files unconditionally echo text, that output gets prepended to your hook's JSON, breaking parsing.
Symptom: Error message "JSON validation failed" or "Unexpected token".
Fix:
# In ~/.zshrc or ~/.bashrc
if [[ $- == *i* ]]; then # Only in interactive shells
echo "Shell ready"
fiNot all tool payloads include every field. A Bash tool call has no file_path. Always guard with // empty:
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE" ]]; then exit 0; fiHooks see only the current tool's JSON input — not the conversation history, prior tool calls, or Claude's reasoning. Design hooks to make decisions based solely on the current tool input.
Hook commands run with the project root as the working directory, but $CLAUDE_FILE_PATH may be absolute or relative. Normalize:
file_path=$(realpath "$file_path" 2>/dev/null || echo "$file_path")When running with claude -p (non-interactive/headless), PermissionRequest hooks do not fire. Use PreToolUse hooks for automated permission decisions in headless/CI contexts.
Returning permissionDecision: "allow" from a PreToolUse hook skips the interactive prompt but does NOT override deny rules from settings. If a deny rule matches the tool call, the call is blocked even when your hook returns "allow".
Running a full test suite, compilation, or slow external API calls on every PreToolUse event will make Claude Code unusably slow. Move expensive checks to Stop hooks.
When modifying tool input via updatedInput, you must also return permissionDecision: "allow". Without it, the modification is ignored.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": { "command": "safe command" }
}
}Test any hook script by piping mock JSON input directly:
# Test a PreToolUse hook
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | bash hooks/bash-guard.sh
echo "Exit code: $?"
# Test a Write hook
echo '{"tool_name":"Write","tool_input":{"file_path":"secrets.env","content":"SECRET=abc"}}' | bash hooks/protect-secrets.sh
echo "Exit code: $?"
# Test a Stop hook
echo '{"stop_hook_active":false,"last_assistant_message":"I have completed the task."}' | bash hooks/run-tests.sh
echo "Exit code: $?"-
Enable verbose mode — Press
Ctrl+Oto see hook output, stderr, and exit codes in the transcript. -
Run with
--debugflag:claude --debug
Shows which hooks matched, their exit codes, and full execution details.
-
Log to a file:
echo "$INPUT" >> /tmp/hook-debug.log echo "Exit: $?" >> /tmp/hook-debug.log
-
Use bash tracing:
#!/bin/bash set -x # Print each command before executing
-
Test JSON output validity:
echo '{"tool_name":"Write","tool_input":{"file_path":"test.txt"}}' | bash my-hook.sh | jq .
-
Verify the /hooks menu — type
/hooksin Claude Code to confirm hooks are registered under the correct event. -
Ask Claude directly:
> Read my settings file and execute the statusLine command to surface any errors
#!/bin/bash
# test-hooks.sh — run all hook tests
PASS=0
FAIL=0
test_hook() {
local desc="$1"
local input="$2"
local expected_exit="$3"
local hook_script="$4"
echo "$input" | bash "$hook_script" > /dev/null 2>&1
actual_exit=$?
if [ "$actual_exit" -eq "$expected_exit" ]; then
echo "PASS: $desc"
((PASS++))
else
echo "FAIL: $desc (expected exit $expected_exit, got $actual_exit)"
((FAIL++))
fi
}
# Test env file protection
test_hook \
"Should block .env write" \
'{"tool_name":"Write","tool_input":{"file_path":"/project/.env"}}' \
2 \
hooks/protect-secrets.sh
test_hook \
"Should allow normal file write" \
'{"tool_name":"Write","tool_input":{"file_path":"/project/src/app.py"}}' \
0 \
hooks/protect-secrets.sh
echo "Results: $PASS passed, $FAIL failed"Type /hooks in Claude Code to open a read-only browser for your configured hooks.
- All hook events with a count of configured hooks for each
- Drill-down into matchers to see filter details
- Full details of each hook handler: event, matcher, type, source file, command
- Source indicators showing where each hook came from
| Label | Source |
|---|---|
User |
~/.claude/settings.json |
Project |
.claude/settings.json |
Local |
.claude/settings.local.json |
Plugin |
Plugin's hooks/hooks.json |
Session |
Registered in memory for current session |
Built-in |
Registered internally by Claude Code |
[command] — Shell command hooks
[prompt] — LLM prompt hooks
[agent] — Subagent verification hooks
[http] — HTTP endpoint hooks
The menu is read-only. To add, modify, or remove hooks, edit the settings JSON directly or ask Claude to make the change. If hooks don't appear after editing settings, the file watcher normally picks up changes automatically; if not, restart the session.
The status line and hooks only run if you have accepted the workspace trust dialog for the current directory, since they execute shell commands. If trust is not accepted, the status line shows a notification instead of your script output.
Hooks receive raw tool inputs including user-controlled data (file paths, shell commands). Avoid passing unvalidated data from hook inputs back to the shell without sanitization. Use jq for safe JSON parsing rather than regex on raw JSON strings.
- Store credentials in environment variables, not in the
commandfield - Use
allowedEnvVarsin HTTP hooks to control which vars are interpolated into headers - Ensure hook script files are not world-readable:
chmod 700 ~/.claude/hooks/
If using hooks for compliance audit trails, ensure the log file is append-only and consider writing to a remote endpoint (HTTP hook) rather than a local file that Claude could modify.
Remember that Anthropic's managed policy settings (organization-level deny lists) take priority over all hook approvals. A PreToolUse hook returning permissionDecision: "allow" cannot override policy-level deny rules.
Before committing hooks to .claude/settings.json (team-shared), ensure all team members understand what the hooks do and that the scripts are accessible to all machines that will run the project.
{
"hooks": {
"EventName": [
{ "matcher": "regex", "hooks": [{ "type": "command", "command": "script.sh" }] }
]
},
"statusLine": { "type": "command", "command": "~/.claude/statusline.sh" },
"disableAllHooks": false
}| Code | Meaning |
|---|---|
0 |
Allow / success — parse stdout JSON |
2 |
Block — use stderr as Claude feedback |
| Other | Allow — non-blocking warning (verbose only) |
| Variable | Use |
|---|---|
$CLAUDE_PROJECT_DIR |
Portable script paths |
$CLAUDE_ENV_FILE |
Persist env vars (SessionStart only) |
$CLAUDE_CODE_REMOTE |
Detect web vs local CLI |
| Event | Filters On |
|---|---|
PreToolUse, PostToolUse, PermissionRequest |
Tool name |
SessionStart |
startup/resume/clear/compact |
Notification |
permission_prompt/idle_prompt/auth_success |
SubagentStart, SubagentStop |
Agent type |
ConfigChange |
Config source |
| Event | Pattern |
|---|---|
PreToolUse |
`hookSpecificOutput.permissionDecision: "allow |
PermissionRequest |
`hookSpecificOutput.decision.behavior: "allow |
Stop, PostToolUse, UserPromptSubmit |
decision: "block" |
TeammateIdle, TaskCompleted |
Exit 2 or continue: false |
WorktreeCreate |
Print path to stdout |
Sources synthesized from:
- Anthropic official documentation: code.claude.com/docs/en/hooks
- Anthropic official documentation: code.claude.com/docs/en/hooks-guide
- Anthropic official documentation: code.claude.com/docs/en/statusline
- Anthropic Agent SDK documentation: platform.claude.com/docs/en/agent-sdk/hooks
- Claude Code in Action course materials (03-hooks-and-the-sdk, lessons 01–06)