title: "Hooks and Commands — Claude Code" tested_with: claude-code: "1.0.x" last_updated: 2026-03-21 status: proven difficulty: intermediate prerequisites: [03-prompting-for-agents]

Hooks and Commands in Claude Code

Claude Code's hooks system lets you attach shell commands to specific events in the agent's workflow. Hooks are configured in JSON, run as subprocesses, and their output is fed back to the agent as context.

Where Hooks Are Configured

Hooks live in your Claude Code settings file. There are two levels:

  • Project-level: .claude/settings.json in your project root. These hooks apply to everyone working on the project (once committed to version control).
  • User-level: ~/.claude/settings.json in your home directory. These hooks apply to all your projects.

Project-level hooks take precedence. If you define a hook for the same event at both levels, both run — project-level hooks first, then user-level hooks.

Hook Events

Claude Code exposes five hook events:

EventWhen It FiresCommon Uses
PreToolUseBefore the agent executes a tool callValidation, blocking, logging
PostToolUseAfter the agent executes a tool callLinting, formatting, testing
NotificationWhen the agent wants to notify the userExternal alerts, logging
StopWhen the agent finishes its responseSummary actions, cleanup
SubagentStopWhen a sub-agent finishesCoordination between agents

Each event provides environment variables with context about what triggered it. The most important ones:

  • $TOOL_NAME — the name of the tool being called (e.g., Bash, Edit, Write)
  • $TOOL_INPUT — the input passed to the tool (JSON-encoded)
  • $TOOL_OUTPUT — the output from the tool (only available in PostToolUse)
  • $SESSION_ID — the current session identifier

Hook Structure

A hook configuration has two parts: a matcher that determines which tool calls trigger the hook, and a hook command that runs when the matcher matches.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "echo 'Running bash command: $TOOL_INPUT'"
      }
    ]
  }
}

The matcher field filters by tool name. Common tool names include Bash, Edit, Write, Read, and Glob. If you omit the matcher, the hook runs for every tool call on that event.

You can also match on content patterns within the tool input. This lets you create hooks that only trigger for specific types of operations — for example, only bash commands that contain git commit.

Hook Behavior

When a hook runs:

  1. The command executes as a subprocess with your user permissions.
  2. Standard output from the hook is captured and fed back to the agent as additional context.
  3. For PreToolUse hooks, a non-zero exit code blocks the tool call. The agent sees the hook's output and can decide how to proceed.
  4. For PostToolUse hooks, exit codes do not block anything (the action already happened), but the output still reaches the agent.
  5. Hooks have a default timeout. Long-running hooks will be killed.

This means your hooks can communicate with the agent. If a linter hook prints error output, the agent sees those errors and can fix them. If a validation hook prints "BLOCKED: cannot commit to main branch," the agent reads that message and adjusts its approach.

10 Production-Ready Hooks

1. Lint on File Save

Run your linter every time the agent edits a file. The agent sees any lint errors and can fix them immediately.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hook": "cd $PROJECT_DIR && npx eslint --no-error-on-unmatched-pattern $(echo $TOOL_INPUT | jq -r '.file_path') 2>&1 || true"
      }
    ]
  }
}

Why it works: The linter output goes directly to the agent. If there are errors, the agent sees them in its next reasoning step and can issue a follow-up edit to fix them. The || true ensures the hook itself does not block — the lint output is informational.

2. Run Tests After Code Changes

Automatically run your test suite after the agent modifies source files.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hook": "cd $PROJECT_DIR && if echo $TOOL_INPUT | jq -r '.file_path' | grep -q 'src/'; then npm test 2>&1 | tail -20; fi"
      }
    ]
  }
}

Why it works: The grep -q 'src/' ensures tests only run when source files change, not when the agent edits configuration or documentation. The tail -20 keeps the output concise so it does not overwhelm the agent's context.

3. Prevent Commits to Main Branch

Block any attempt to commit directly to the main or master branch.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "if echo $TOOL_INPUT | grep -q 'git commit'; then BRANCH=$(git -C $PROJECT_DIR rev-parse --abbrev-ref HEAD); if [ \"$BRANCH\" = 'main' ] || [ \"$BRANCH\" = 'master' ]; then echo 'BLOCKED: Cannot commit directly to $BRANCH. Create a feature branch first.' && exit 1; fi; fi"
      }
    ]
  }
}

Why it works: This is a PreToolUse hook with a non-zero exit code, which means it actually prevents the tool call from executing. The agent sees the "BLOCKED" message and knows to create a branch first.

4. Log All Bash Commands for Audit

Write every bash command the agent runs to a log file with timestamps.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "echo \"$(date -Iseconds) | $SESSION_ID | $TOOL_INPUT\" >> $PROJECT_DIR/.claude/agent-commands.log"
      }
    ]
  }
}

Why it works: You get a complete, timestamped record of every command the agent executed. Useful for auditing, debugging, and understanding what the agent did during long sessions. Add .claude/agent-commands.log to your .gitignore.

5. Auto-Format Code After Edits

Run your code formatter after every file edit so the agent's output always matches your project's style.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hook": "cd $PROJECT_DIR && FILE=$(echo $TOOL_INPUT | jq -r '.file_path') && if echo $FILE | grep -qE '\\.(ts|tsx|js|jsx)$'; then npx prettier --write $FILE 2>&1; fi"
      }
    ]
  }
}

Why it works: Instead of telling the agent to format its code (which it may forget), the formatter runs automatically. The agent sees the formatter's output and learns the actual style, which improves future edits in the same session.

6. Notify on Task Completion

Send a desktop notification when the agent finishes a task, so you can work on other things while it runs.

{
  "hooks": {
    "Stop": [
      {
        "hook": "notify-send 'Claude Code' 'Task completed' 2>/dev/null || osascript -e 'display notification \"Task completed\" with title \"Claude Code\"' 2>/dev/null || true"
      }
    ]
  }
}

Why it works: The hook tries Linux (notify-send) and macOS (osascript) notification methods. The 2>/dev/null || true fallbacks ensure it does not fail on platforms where one method is unavailable.

7. Validate Environment Before Destructive Operations

Check that critical environment variables or files exist before allowing potentially destructive commands.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "if echo $TOOL_INPUT | grep -qE '(rm -rf|drop table|truncate|delete from)'; then if [ ! -f $PROJECT_DIR/.claude/destructive-ops-allowed ]; then echo 'BLOCKED: Destructive operation detected. Create .claude/destructive-ops-allowed to enable.' && exit 1; fi; fi"
      }
    ]
  }
}

Why it works: This adds a manual gate for dangerous operations. The agent cannot accidentally run rm -rf or database destructive commands unless you have explicitly opted in by creating a marker file.

8. Block Certain File Patterns from Editing

Prevent the agent from modifying files that should be hand-maintained, like migration files or generated code.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit",
        "hook": "FILE=$(echo $TOOL_INPUT | jq -r '.file_path') && if echo $FILE | grep -qE '(migrations/|generated/|\\.lock$)'; then echo \"BLOCKED: $FILE is in a protected path. These files should not be edited by the agent.\" && exit 1; fi"
      }
    ]
  }
}

Why it works: Some files should never be touched by an automated tool — database migrations that have already been applied, lock files managed by package managers, generated code that will be overwritten. This hook makes those boundaries hard rather than advisory.

9. Add Timestamps to Generated Comments

Append a timestamp to any code comments the agent generates, so you can track when agent-generated code was written.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hook": "FILE=$(echo $TOOL_INPUT | jq -r '.file_path') && if [ -f \"$FILE\" ]; then sed -i \"s|// TODO|// TODO (agent $(date +%Y-%m-%d))|g\" $FILE 2>/dev/null; fi"
      }
    ]
  }
}

Why it works: This gives you a paper trail. When you encounter a TODO six months from now, the date tells you when it was created and that it came from an agent session. Adjust the pattern to match your project's comment convention.

10. Custom Commit Message Formatting

Ensure all commits made by the agent follow your team's commit message convention.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "if echo $TOOL_INPUT | grep -q 'git commit'; then if ! echo $TOOL_INPUT | grep -qE 'git commit -m \"(feat|fix|docs|refactor|test|chore):'; then echo 'BLOCKED: Commit message must follow conventional commits format (feat|fix|docs|refactor|test|chore): description' && exit 1; fi; fi"
      }
    ]
  }
}

Why it works: Rather than hoping the agent remembers your commit message format from the CLAUDE.md, this hook enforces it structurally. The agent sees the required format in the error message and retries with a properly formatted commit.

Custom Slash Commands

Claude Code supports custom slash commands — shortcuts you invoke with / followed by a command name. These are defined as Markdown files in your project's .claude/commands/ directory.

Creating a Command

Create a file at .claude/commands/test.md:

Run the full test suite for this project. Report:
1. Total tests run
2. Tests passed
3. Tests failed (with details for each failure)
4. Test coverage percentage if available

Use the project's standard test runner. Do not modify any test files.

Now you can type /test in Claude Code, and it will execute this workflow.

Command File Structure

Each command file is a Markdown document that serves as a prompt template. The filename (without .md) becomes the command name. You can organize commands in subdirectories:

.claude/
  commands/
    test.md           -> /test
    review.md         -> /review
    deploy-check.md   -> /deploy-check
    db/
      migrate.md      -> /db:migrate
      seed.md         -> /db:seed

Variable Substitution

Commands support the $ARGUMENTS placeholder, which is replaced with whatever the user types after the command name. For example, if review.md contains:

Review the following file for potential bugs, security issues, and style violations: $ARGUMENTS

Then typing /review src/auth.ts passes src/auth.ts into the prompt.

Team-Shared Commands

Because commands live in .claude/commands/, they are version-controlled with your project. Any team member who clones the repository gets the same set of commands. This is a good way to standardize workflows across a team without requiring everyone to configure their individual settings.

Good candidates for team-shared commands:

  • Running the test suite and interpreting results
  • Performing a pre-merge review checklist
  • Generating boilerplate for new components, endpoints, or modules
  • Running database operations in the correct sequence
  • Checking for common security issues

Debugging Hooks That Are Not Working

Hooks fail silently by default. If a hook is not doing what you expect, work through this checklist:

1. Verify the settings file location. Run claude config list or check that your .claude/settings.json is in the project root (not a subdirectory). User-level settings go in ~/.claude/settings.json.

2. Check JSON syntax. A single misplaced comma or missing bracket will cause the entire settings file to be ignored. Run your settings file through jq . to validate:

jq . .claude/settings.json

3. Test the hook command manually. Copy the hook command and run it in your terminal with the environment variables set manually. This isolates whether the problem is in the hook logic or in the hook system:

export TOOL_INPUT='{"command":"git status"}'
export PROJECT_DIR=$(pwd)
# paste your hook command here and run it

4. Check the matcher. Tool names are case-sensitive. bash will not match — it must be Bash. Common tool names: Bash, Edit, Write, Read, Glob, Grep.

5. Check for timeout issues. Hooks that take too long will be killed. If your hook runs a slow command (a full test suite, a remote API call), it may be hitting the timeout. Test the command's execution time independently.

6. Look for permission errors. If the hook calls a tool that requires specific permissions or environment variables, make sure those are available in the hook's execution context. Hooks inherit your user environment but may not have the same shell initialization (.bashrc, .zshrc may not be sourced).

7. Use echo statements for tracing. Add echo "HOOK FIRED: ..." to the beginning of your hook command. If you see the output in the agent's context, the hook is firing. If not, the matcher is not matching.

8. Check for conflicting hooks. If you have hooks at both the project and user level for the same event, they both run. This can cause unexpected behavior if they interact — for example, two hooks both trying to format the same file.

Putting It Together

A well-configured Claude Code project typically has:

  • A CLAUDE.md that describes conventions and intent.
  • A .claude/settings.json with 2-5 hooks that enforce the most critical of those conventions.
  • A .claude/commands/ directory with 3-10 custom commands for common workflows.

The CLAUDE.md tells the agent what to do. The hooks ensure it happens. The commands give you efficient shortcuts. Together, they create a development environment where the agent is not just capable but consistently aligned with your project's standards.

Start minimal. Add hooks and commands as you discover pain points. Review your configuration monthly and remove anything that is not pulling its weight.