Part 2 of 7
🤖 Ghostwritten by Claude Opus 4.5 · Curated by Tom Hundley
This article was written by Claude Opus 4.5 and curated for publication by Tom Hundley.
Hooks are Claude Code's event-driven automation system. They let you run scripts before Claude uses a tool, after it makes changes, when a session starts, or when Claude finishes a task. Think of them as automated quality gates that enforce your team's standards without manual intervention.
In Part 1, we covered CLAUDE.md for persistent context. Hooks take that further—they're not just instructions, they're executable enforcement.
Without hooks, you're constantly checking Claude's work manually:
With hooks, these checks happen automatically. A PostToolUse hook can auto-format TypeScript files after every edit. A PreToolUse hook can block writes to .env files. A Stop hook can run your test suite before Claude considers a task complete.
Hooks are defined in your settings files at any scope:
~/.claude/settings.json — User-level (all projects).claude/settings.json — Project-level (team-shared).claude/settings.local.json — Local project (personal, gitignored)The structure follows this pattern:
{
"hooks": {
"EventName": [
{
"matcher": "ToolPattern",
"hooks": [
{
"type": "command",
"command": "your-script-here"
}
]
}
]
}
}Claude Code fires hooks at specific lifecycle points. Here's every event you can tap into:
| Event | When It Fires | Matchers | Use Case |
|---|---|---|---|
| PreToolUse | Before a tool executes | Yes | Block dangerous operations, validate inputs |
| PostToolUse | After a tool succeeds | Yes | Auto-format, run linters, validate output |
| PermissionRequest | When permission dialog shows | Yes | Auto-approve or auto-deny based on context |
| Event | When It Fires | Matchers | Use Case |
|---|---|---|---|
| SessionStart | On startup, resume, clear, compact | By trigger type | Load context, install dependencies |
| SessionEnd | When session terminates | No | Cleanup, logging |
| Event | When It Fires | Matchers | Use Case |
|---|---|---|---|
| Stop | When main agent finishes a turn | No | Run tests, quality gates, summarize status |
| SubagentStop | When a subagent finishes | No | Validate subagent output |
| UserPromptSubmit | Before processing user input | No | Add context, validate, block unsafe prompts |
| Event | When It Fires | Matchers | Use Case |
|---|---|---|---|
| Notification | On system notifications | By notification type | Custom alerts, logging |
| PreCompact | Before context is compacted | By trigger (manual/auto) | Preserve critical context |
Matchers determine which tools trigger your hooks. They only apply to PreToolUse, PostToolUse, and PermissionRequest events.
Match a specific tool exactly:
{
"matcher": "Write"
}This triggers only for the Write tool.
Use pipe syntax to match multiple tools:
{
"matcher": "Write|Edit"
}Triggers for either Write or Edit.
Match all tools:
{
"matcher": "*"
}Or simply omit the matcher field for the same effect.
For MCP server tools, match the full tool name:
{
"matcher": "mcp__github__create_pull_request"
}Run shell commands when triggered:
{
"type": "command",
"command": "/path/to/script.sh",
"timeout": 60
}The timeout is in seconds (default: 60). Commands receive JSON input via stdin and communicate results via exit codes and stdout.
Use an LLM to evaluate the situation:
{
"type": "prompt",
"prompt": "Evaluate if this code change follows our security guidelines: $ARGUMENTS",
"timeout": 30
}Prompt hooks are powerful for nuanced decisions that can't be codified in a simple script.
Every hook receives a JSON object via stdin:
{
"session_id": "abc123",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/working/directory",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Write",
"tool_input": {
"file_path": "/src/app/page.tsx",
"content": "..."
},
"tool_use_id": "toolu_..."
}Your script can parse this to make decisions about what to do.
| Exit Code | Meaning | Behavior |
|---|---|---|
| 0 | Success | Continue normally; stdout shown in verbose mode |
| 2 | Blocking error | Block the action; stderr shown to Claude |
| Other | Non-blocking error | Continue; error shown in verbose mode |
For advanced control, return JSON in stdout:
{
"decision": "block",
"reason": "File is in protected directory",
"continue": false,
"systemMessage": "Warning: Attempted to modify protected file"
}For PreToolUse, you can even modify the tool input:
{
"hookSpecificOutput": {
"updatedInput": {
"file_path": "/corrected/path.tsx"
}
}
}Run Prettier automatically after any TypeScript file is edited:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | { read file_path; if echo \"$file_path\" | grep -qE '\\.tsx?$'; then npx prettier --write \"$file_path\"; fi; }"
}
]
}
]
}
}Prevent edits to sensitive files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json, sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); sys.exit(2 if any(x in p for x in ['.env','.git/','package-lock.json']) else 0)\""
}
]
}
]
}
}Exit code 2 blocks the action and tells Claude why it couldn't proceed.
Create an audit trail of every command Claude runs:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"[\\(.session_id)] \\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-command-log.txt"
}
]
}
]
}
}Run tests and linting before Claude considers a task complete:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm run lint && npm run type-check"
}
]
}
]
}
}If the command fails (non-zero exit), Claude is informed and can attempt to fix the issues.
Ensure dependencies are installed when starting a session:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "npm install --silent"
}
]
}
]
}
}The matcher for SessionStart accepts: startup, resume, clear, compact.
Skip permission prompts for known-safe tools:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "Read|Glob|Grep",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"allow\"}}'"
}
]
}
]
}
}Multiple hooks on the same event run in parallel:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{ "type": "command", "command": "npx prettier --write $(jq -r '.tool_input.file_path')" },
{ "type": "command", "command": "npx eslint --fix $(jq -r '.tool_input.file_path')" }
]
}
]
}
}Both Prettier and ESLint run simultaneously after a file is written.
For nuanced decisions, use prompt hooks:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the changes made in this session. Are there any security concerns, breaking changes, or missing test coverage? If yes, explain what additional work is needed."
}
]
}
]
}
}Hooks have access to CLAUDE_PROJECT_DIR for the project root. Use this for portable scripts:
#!/bin/bash
cd "$CLAUDE_PROJECT_DIR"
npm run lintRun Claude with verbose output to see hook execution:
claude --verboseBefore deploying a hook, test it manually:
echo '{"tool_name":"Write","tool_input":{"file_path":"/test.ts"}}' | ./your-hook.sh
echo $? # Check exit codejq or proper JSON parsing$CLAUDE_PROJECT_DIR for portability.claude/settings.local.json before committingHooks automate quality enforcement, but Claude Code's real power comes from connecting to external systems. In Part 3, we'll explore MCP Server Integration—connecting Claude to GitHub, databases, and any API you can imagine.
Up next: Part 3: MCP Server Integration — Extend Claude Code's capabilities by connecting to external services through the Model Context Protocol.
Discover more content: