Back to all articles
10 MIN READ

Claude Code Hooks: Automate Your Development Workflow

By Learnia Team

Claude Code Hooks: Automate Your Development Workflow

This article is written in English. Our training modules are available in French.

Hooks intercept Claude Code's actions before or after execution. They let you inject custom logic—validation, logging, notifications, transformations—into Claude's workflow. Think of hooks as middleware for your AI assistant.


What Are Hooks?

Hooks are scripts that run at specific points in Claude Code's lifecycle:

Hook Execution Flow:

  1. User Request → You ask Claude to do something
  2. PreToolUse Hook → Your script runs first (can block, modify, or proceed)
  3. Tool Runs → Claude executes the action
  4. PostToolUse Hook → Your script runs after (can log, notify, or transform)
  5. Response to User → You see the result

Hook Types

HookWhen It RunsPurpose
PreToolUse
Before any tool executesValidate, block, or modify
PostToolUse
After a tool completesLog, notify, transform output
Notification
On specific eventsAlert on errors, completions
SessionStart
When a session beginsInitialize environment
SessionStop
When a session endsCleanup, summarize

Why Use Hooks?

Without Hooks

You manually check each action:

Claude wants to run: rm -rf ./temp

[y] Accept  [n] Reject

Every. Single. Time.

With Hooks

Automation handles routine checks:

Claude wants to run: rm -rf ./temp
[PreToolUse] ✓ Validated: temp directory only
Proceeding automatically...

Claude wants to run: rm -rf /
[PreToolUse] ✗ Blocked: root directory access denied

Safe commands proceed; dangerous ones block automatically.


Hook Configuration

Hooks are configured in

~/.claude/settings.json
or
.claude/settings.json
:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "command": "python .claude/hooks/validate-bash.py"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "command": "python .claude/hooks/log-actions.py"
      }
    ]
  }
}

Hook Object Properties

PropertyDescription
matcher
Tool pattern to match (
Bash
,
Write
,
*
for all)
command
Script to execute
timeout
Max execution time (ms)
onError
Action on failure:
proceed
or
block

PreToolUse Hooks

PreToolUse hooks run before Claude executes a tool. They can:

  • Proceed: Allow the action
  • Block: Deny the action
  • Modify: Change parameters

Input Format

Your hook receives JSON on stdin:

{
  "hook_type": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm install lodash"
  },
  "session_id": "abc123",
  "timestamp": "2026-01-12T10:30:00Z"
}

Output Format

Return JSON on stdout:

{
  "decision": "proceed"
}

Decision options:

DecisionBehavior
proceed
Allow the tool to run
block
Deny execution
modify
Change tool input

Block Example

# .claude/hooks/validate-bash.py
import json
import sys

input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")

# Block dangerous commands
dangerous_patterns = [
    "rm -rf /",
    "rm -rf ~",
    "sudo rm",
    "> /dev/sda",
    "mkfs",
    "dd if="
]

for pattern in dangerous_patterns:
    if pattern in command:
        result = {
            "decision": "block",
            "reason": f"Blocked dangerous command containing: {pattern}"
        }
        print(json.dumps(result))
        sys.exit(0)

# Allow everything else
print(json.dumps({"decision": "proceed"}))

Modify Example

# .claude/hooks/add-dry-run.py
import json
import sys

input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")

# Add --dry-run to destructive commands
if command.startswith("kubectl delete"):
    result = {
        "decision": "modify",
        "tool_input": {
            "command": command + " --dry-run=client"
        },
        "reason": "Added --dry-run for safety"
    }
else:
    result = {"decision": "proceed"}

print(json.dumps(result))

PostToolUse Hooks

PostToolUse hooks run after a tool completes. They receive the tool's output and can:

  • Log actions
  • Send notifications
  • Transform output
  • Trigger follow-up actions

Input Format

{
  "hook_type": "PostToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "npm test"
  },
  "tool_output": {
    "stdout": "All tests passed",
    "stderr": "",
    "exit_code": 0
  },
  "session_id": "abc123",
  "timestamp": "2026-01-12T10:30:00Z"
}

Logging Example

# .claude/hooks/log-actions.py
import json
import sys
from datetime import datetime

input_data = json.loads(sys.stdin.read())

log_entry = {
    "timestamp": datetime.now().isoformat(),
    "tool": input_data.get("tool_name"),
    "input": input_data.get("tool_input"),
    "output_preview": str(input_data.get("tool_output", {}))[:200]
}

with open(".claude/logs/actions.jsonl", "a") as f:
    f.write(json.dumps(log_entry) + "\n")

# PostToolUse hooks should return empty or minimal response
print(json.dumps({"status": "logged"}))

Notification Example

# .claude/hooks/notify-errors.py
import json
import sys
import requests

input_data = json.loads(sys.stdin.read())
tool_output = input_data.get("tool_output", {})

# Check for errors
exit_code = tool_output.get("exit_code", 0)
stderr = tool_output.get("stderr", "")

if exit_code != 0 or stderr:
    # Send to Slack
    webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
    requests.post(webhook_url, json={
        "text": f"⚠️ Claude Code error:\n```{stderr}```"
    })

print(json.dumps({"status": "processed"}))

Notification Hooks

Notification hooks respond to system events rather than tool usage:

{
  "hooks": {
    "Notification": [
      {
        "event": "task_complete",
        "command": "python .claude/hooks/notify-complete.py"
      },
      {
        "event": "error",
        "command": "python .claude/hooks/notify-error.py"
      }
    ]
  }
}

Event Types

EventDescription
task_complete
Claude finished a task
error
An error occurred
context_limit
Approaching context limit
cost_threshold
Cost exceeded threshold

Task Completion Notification

# .claude/hooks/notify-complete.py
import json
import sys
import os

input_data = json.loads(sys.stdin.read())

# macOS notification
os.system(f"""osascript -e 'display notification "Task complete" with title "Claude Code"'""")

# Or desktop-notifier for cross-platform
# from desktop_notifier import DesktopNotifier
# notifier = DesktopNotifier()
# await notifier.send(title="Claude Code", message="Task complete")

print(json.dumps({"status": "notified"}))

Session Hooks

SessionStart

Runs when Claude Code starts:

{
  "hooks": {
    "SessionStart": [
      {
        "command": "python .claude/hooks/session-start.py"
      }
    ]
  }
}

Use cases:

  • Set environment variables
  • Check prerequisites
  • Load session context
# .claude/hooks/session-start.py
import json
import sys
import os

# Check required tools
required = ["node", "npm", "git"]
missing = [cmd for cmd in required if os.system(f"which {cmd} > /dev/null") != 0]

if missing:
    result = {
        "warning": f"Missing tools: {', '.join(missing)}"
    }
else:
    result = {"status": "ready"}

print(json.dumps(result))

SessionStop

Runs when Claude Code exits:

# .claude/hooks/session-stop.py
import json
import sys

input_data = json.loads(sys.stdin.read())

# Generate session summary
summary = {
    "session_id": input_data.get("session_id"),
    "duration": input_data.get("duration_seconds"),
    "tools_used": input_data.get("tool_count"),
    "tokens_used": input_data.get("token_count")
}

with open(".claude/logs/sessions.jsonl", "a") as f:
    f.write(json.dumps(summary) + "\n")

print(json.dumps({"status": "logged"}))

Matcher Patterns

Matchers determine which tools trigger a hook:

PatternMatches
Bash
All Bash commands
Bash(npm:*)
npm commands only
Bash(git:*)
git commands only
Write
All file writes
Write(src/**)
Writes to src folder
Edit
All file edits
*
All tools
Read,Write,Edit
Multiple tools

Multiple Matchers

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash(rm:*),Bash(git:push --force*)",
        "command": "python .claude/hooks/confirm-dangerous.py"
      }
    ]
  }
}

Real-World Hook Recipes

Recipe 1: Command Allowlist

Only permit specific commands:

# .claude/hooks/allowlist.py
import json
import sys

input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")

ALLOWED_PREFIXES = [
    "npm ",
    "yarn ",
    "git ",
    "node ",
    "python ",
    "pytest ",
    "echo ",
    "cat ",
    "ls ",
    "pwd"
]

allowed = any(command.startswith(prefix) for prefix in ALLOWED_PREFIXES)

if allowed:
    print(json.dumps({"decision": "proceed"}))
else:
    print(json.dumps({
        "decision": "block",
        "reason": f"Command not in allowlist: {command.split()[0]}"
    }))

Recipe 2: Audit Log

Create a detailed audit trail:

# .claude/hooks/audit-log.py
import json
import sys
import os
from datetime import datetime

input_data = json.loads(sys.stdin.read())

audit_entry = {
    "timestamp": datetime.now().isoformat(),
    "user": os.environ.get("USER"),
    "session": input_data.get("session_id"),
    "tool": input_data.get("tool_name"),
    "action": input_data.get("tool_input"),
    "hook_type": input_data.get("hook_type")
}

log_file = os.path.expanduser("~/.claude/logs/audit.jsonl")
os.makedirs(os.path.dirname(log_file), exist_ok=True)

with open(log_file, "a") as f:
    f.write(json.dumps(audit_entry) + "\n")

print(json.dumps({"decision": "proceed"}))

Recipe 3: Slack Notifications

Alert team on specific events:

# .claude/hooks/slack-notify.py
import json
import sys
import os
import requests

input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})

WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")

# Notify on deployments
if tool_name == "Bash":
    command = tool_input.get("command", "")
    if "deploy" in command or "kubectl apply" in command:
        requests.post(WEBHOOK_URL, json={
            "text": f"🚀 Deployment initiated:\n```{command}```"
        })

print(json.dumps({"decision": "proceed"}))

Recipe 4: Git Safety

Prevent dangerous git operations:

# .claude/hooks/git-safety.py
import json
import sys

input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")

if not command.startswith("git "):
    print(json.dumps({"decision": "proceed"}))
    sys.exit(0)

BLOCKED_PATTERNS = [
    "git push --force",
    "git push -f",
    "git reset --hard",
    "git clean -fd",
    "git checkout -- .",  # discard all changes
]

PROTECTED_BRANCHES = ["main", "master", "production"]

for pattern in BLOCKED_PATTERNS:
    if pattern in command:
        print(json.dumps({
            "decision": "block",
            "reason": f"Dangerous git operation: {pattern}"
        }))
        sys.exit(0)

# Check protected branches
for branch in PROTECTED_BRANCHES:
    if f"push origin {branch}" in command or f"push -f origin {branch}" in command:
        print(json.dumps({
            "decision": "block",
            "reason": f"Direct push to protected branch: {branch}"
        }))
        sys.exit(0)

print(json.dumps({"decision": "proceed"}))

Recipe 5: Test Verification

Run tests before allowing merges:

# .claude/hooks/require-tests.py
import json
import sys
import subprocess

input_data = json.loads(sys.stdin.read())
command = input_data.get("tool_input", {}).get("command", "")

# Check if this is a merge operation
if "git merge" in command or "git push" in command:
    # Run tests first
    result = subprocess.run(
        ["npm", "test"],
        capture_output=True,
        text=True
    )
    
    if result.returncode != 0:
        print(json.dumps({
            "decision": "block",
            "reason": f"Tests must pass before merge/push:\n{result.stderr}"
        }))
        sys.exit(0)

print(json.dumps({"decision": "proceed"}))

Hook Debugging

Enable Verbose Mode

claude --verbose

Shows hook execution:

[HOOK] PreToolUse: Running validate-bash.py
[HOOK] Input: {"tool_name": "Bash", "tool_input": {"command": "npm test"}}
[HOOK] Output: {"decision": "proceed"}
[HOOK] Duration: 45ms

Test Hooks Manually

echo '{"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}}' | python .claude/hooks/validate-bash.py

Common Issues

Hook not running:

  • Check file path in settings
  • Verify matcher pattern matches tool
  • Ensure script is executable

Hook timing out:

  • Increase
    timeout
    in config
  • Optimize script performance
  • Use async for slow operations

JSON parse errors:

  • Validate JSON output from your script
  • Check for extra print statements
  • Ensure clean stdout/stderr separation

Hooks + Other Features

Hooks + Custom Commands

Commands can trigger hooks:

<!-- .claude/commands/safe-deploy.md -->
Deploy to production. Hooks will:
- Validate deployment criteria
- Log the deployment
- Notify the team

The command's Bash calls trigger your hooks automatically.

Hooks + Permissions

Hooks add another layer to permissions:

Request: git push --force origin main

1. Permission check: Is Bash(git:*) allowed? → YES
2. PreToolUse hook: Is this safe? → BLOCKED (protected branch)

See Claude Code Permissions: Deny, Allow & Ask Modes Explained.

Hooks + MCP

MCP tool calls trigger hooks too:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__github__*",
        "command": "python .claude/hooks/validate-github.py"
      }
    ]
  }
}

See Model Context Protocol (MCP) for Claude Code: Complete Guide.


Performance Considerations

  1. Keep hooks fast: Target <100ms execution
  2. Async for slow operations: Don't block on network calls in PreToolUse
  3. Cache when possible: Store computed values between calls
  4. Use specific matchers:
    Bash(git:*)
    is faster than
    *
  5. Batch logging: Write logs in batches, not per-event

Key Takeaways

  1. PreToolUse for validation: Block or modify dangerous actions before they run.

  2. PostToolUse for observation: Log, notify, and analyze without blocking.

  3. Matchers for precision: Target specific tools to minimize overhead.

  4. JSON in, JSON out: Hooks communicate via simple JSON protocol.

  5. Layer with permissions: Hooks complement the permission system.


Build Autonomous AI Agents

Hooks are one piece of building reliable autonomous agents. Learn the broader principles of agent orchestration.

In our Module 6 — Autonomous Agents, you'll learn:

  • Agent architecture patterns
  • Orchestration and control loops
  • Error handling and recovery
  • Building reliable AI workflows

Explore Module 6: Autonomous Agents

GO DEEPER

Module 6 — AI Agents & ReAct

Create autonomous agents that reason and take actions.