title: "Exercises — Hooks and Commands" last_updated: 2026-03-21 status: proven difficulty: intermediate prerequisites: [03-prompting-for-agents]

Exercises — Hooks and Commands

These exercises build your practical skills with hooks and custom commands. Each one addresses a real customization need that you will encounter in production use. Work through them in order — each exercise builds on concepts from the previous one.


Exercise 1: Your First Hook

Objective

Add a simple logging hook that prints a message every time the agent runs a bash command. Verify that it works by running a task. Then refine it to only log commands that modify files.

Steps

  1. Open your project's .claude/settings.json file (create it if it does not exist). If you do not have a project handy, create a temporary one with mkdir -p /tmp/hook-practice/.claude && cd /tmp/hook-practice.

  2. Add a PreToolUse hook that targets the Bash tool and prints a message. Start with this configuration:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "echo '[HOOK] Bash command detected: '$(echo $TOOL_INPUT | head -c 200)"
      }
    ]
  }
}
  1. Start a Claude Code session and ask the agent to do something that involves bash commands — for example, "List all files in this directory and show me the disk usage."

  2. Observe: does the [HOOK] message appear in the agent's output? If yes, the hook is firing correctly.

  3. Now refine the hook. Modify it so that it only logs bash commands that are likely to modify files. Change the hook command to check for keywords like rm, mv, cp, mkdir, touch, sed, chmod, or redirect operators (>, >>):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "if echo $TOOL_INPUT | grep -qE '(rm |mv |cp |mkdir |touch |sed |chmod |> |>>)'; then echo '[HOOK] File-modifying command detected: '$(echo $TOOL_INPUT | head -c 200); fi"
      }
    ]
  }
}
  1. Test again with a task that involves both read-only commands (like ls, cat, git status) and file-modifying commands. Verify that only the modifying commands trigger the log message.

Expected Outcome

  • The basic hook fires on every bash command and you see the [HOOK] prefix in the agent's context.
  • The refined hook only fires on commands that modify files.
  • You understand the feedback loop: hook output goes to the agent, which means the agent "sees" your log messages.

Hints

  • If the hook does not fire, check that the matcher value is Bash with a capital B. Tool names are case-sensitive.
  • If the settings file is ignored entirely, validate your JSON with jq . .claude/settings.json. A syntax error anywhere in the file causes the whole file to be skipped.
  • The head -c 200 truncation is important — without it, very long commands will flood the agent's context.
  • Remember that hook output is visible to the agent. In a real workflow, you would probably log to a file instead of echoing to stdout, unless you want the agent to react to the log message.

Exercise 2: The Quality Gate

Objective

Create a hook that runs your project's linter after every file edit. If the lint fails, the output should inform the agent so it can fix the issues automatically.

Steps

  1. Choose a project that has a linter configured. If you do not have one, set up a minimal project:
mkdir -p /tmp/lint-practice/src && cd /tmp/lint-practice
npm init -y
npm install --save-dev eslint
npx eslint --init  # choose a basic configuration
mkdir -p .claude
  1. Create a source file with an intentional lint error:
// src/example.js
var x = 1
var y = 2
console.log(x)
// y is declared but never used — this should trigger a lint warning
  1. Add a PostToolUse hook to .claude/settings.json that runs the linter after any Edit or Write operation:
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hook": "cd $PROJECT_DIR && npx eslint $(echo $TOOL_INPUT | jq -r '.file_path') 2>&1 || true"
      },
      {
        "matcher": "Write",
        "hook": "cd $PROJECT_DIR && npx eslint $(echo $TOOL_INPUT | jq -r '.file_path') 2>&1 || true"
      }
    ]
  }
}
  1. Start a Claude Code session and ask the agent: "Edit src/example.js to add a new function called add that takes two numbers and returns their sum."

  2. Observe: after the agent edits the file, the linter runs automatically. If there are lint errors (including the pre-existing y unused variable), the agent should see them in its context.

  3. Follow up with: "Fix any lint errors in the file." The agent should use the lint output it already received to identify and fix the issues.

  4. Verify that after the fix, the linter hook runs again and reports no errors.

Expected Outcome

  • The linter runs automatically after every edit — you never have to ask for it.
  • The agent sees lint output and can act on it.
  • After the agent fixes the lint errors, the subsequent lint run is clean.
  • You have a working quality gate that ensures code is always linted.

Hints

  • The || true at the end of the hook command is critical. Without it, lint failures cause the hook to exit with a non-zero code. For PostToolUse hooks this does not block anything, but it can produce confusing error messages.
  • If you use a linter other than ESLint (Ruff for Python, Clippy for Rust, etc.), adjust the command accordingly. The pattern is the same: run the linter on the edited file and let the output flow back to the agent.
  • If jq is not installed, you can use a simpler approach: echo $TOOL_INPUT | python3 -c "import sys,json; print(json.load(sys.stdin)['file_path'])".
  • Watch the agent's behavior carefully. Some agents will preemptively fix lint issues on subsequent edits because they learned from the hook output earlier in the session. This is the feedback loop working as intended.

Exercise 3: Custom Command

Objective

Create a custom slash command for Claude Code that runs your project's full test suite and reports results in a structured format. The command should be reusable by any team member who clones the repository.

Steps

  1. Set up a project with a test suite. If you do not have one, create a minimal project:
mkdir -p /tmp/command-practice/src && cd /tmp/command-practice
npm init -y
npm install --save-dev jest
mkdir -p .claude/commands

Create a simple source file and test:

// src/math.js
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
module.exports = { add, subtract };
// src/math.test.js
const { add, subtract } = require('./math');
test('add', () => expect(add(1, 2)).toBe(3));
test('subtract', () => expect(subtract(5, 3)).toBe(2));

Add to package.json: "scripts": { "test": "jest" }

  1. Create the custom command file at .claude/commands/test.md:
Run the project's full test suite and report the results.

Steps:
1. Run `npm test` and capture the full output
2. Report the results in this exact format:

## Test Results

- **Total tests**: [number]
- **Passed**: [number]
- **Failed**: [number]
- **Duration**: [time]

### Failures (if any)
For each failed test, report:
- Test name
- Expected vs. actual
- Relevant file and line

### Summary
One sentence: are we good to ship, or are there issues to address?

Do not modify any source or test files. This is a read-only operation.
  1. Start a Claude Code session and type /test. Observe the agent executing the test suite and formatting the results according to your command template.

  2. Now create a second command. Create .claude/commands/review.md:

Review the file specified by the user for code quality issues.

$ARGUMENTS

Check for:
1. **Bugs**: Logic errors, off-by-one errors, null/undefined risks
2. **Security**: Input validation, injection risks, hardcoded secrets
3. **Performance**: Unnecessary loops, missing early returns, repeated computations
4. **Style**: Naming conventions, function length, comment quality
5. **Testing**: Is this code adequately tested? What test cases are missing?

Format findings as a numbered list with severity labels: CRITICAL, WARNING, or INFO.
End with a one-line summary of overall code health.
  1. Test with /review src/math.js. Verify that the $ARGUMENTS placeholder is replaced with the file path.

  2. Commit both command files to version control. Verify that a teammate (or you, in a fresh clone) has access to the same commands.

Expected Outcome

  • /test runs the test suite and produces a formatted report without you specifying the steps each time.
  • /review <file> runs a structured code review with the $ARGUMENTS substitution working correctly.
  • Both commands are checked into the repository under .claude/commands/ and available to anyone who clones the project.
  • You understand how custom commands reduce repetition for common workflows.

Hints

  • The command filename (without .md) becomes the slash command name. Use kebab-case for multi-word commands: deploy-check.md becomes /deploy-check.
  • Commands in subdirectories use colon separators: .claude/commands/db/migrate.md becomes /db:migrate.
  • Keep commands focused on a single workflow. If a command tries to do too many things, split it into multiple commands.
  • The $ARGUMENTS placeholder only works if included in the command file. If you forget it, the command ignores any arguments the user passes.
  • Commands are essentially prompt templates. They do not execute code directly — they instruct the agent. The agent then decides which tools to use. This means you should write commands in clear, imperative language.

Exercise 4: The Hook Audit

Objective

Install five hooks from the examples in this module. Use the agent for 30 minutes of real work with all hooks active. Evaluate which hooks were genuinely useful and which caused friction. Remove the ones that were not helpful. Document your reasoning.

Steps

  1. Choose a real project you actively work on. This exercise only works with genuine tasks, not toy examples.

  2. Install these five hooks in your .claude/settings.json. Adapt the commands to your project's language and tooling:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hook": "echo \"$(date -Iseconds) | $TOOL_INPUT\" >> .claude/agent-commands.log"
      },
      {
        "matcher": "Bash",
        "hook": "if echo $TOOL_INPUT | grep -q 'git commit'; then BRANCH=$(git rev-parse --abbrev-ref HEAD); if [ \"$BRANCH\" = 'main' ] || [ \"$BRANCH\" = 'master' ]; then echo 'BLOCKED: Cannot commit to main/master.' && exit 1; fi; fi"
      },
      {
        "matcher": "Edit",
        "hook": "FILE=$(echo $TOOL_INPUT | jq -r '.file_path') && if echo $FILE | grep -qE '(\\.lock$|migrations/)'; then echo \"BLOCKED: $FILE is protected.\" && exit 1; fi"
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hook": "cd $PROJECT_DIR && FILE=$(echo $TOOL_INPUT | jq -r '.file_path') && if echo $FILE | grep -qE '\\.(js|ts|py)$'; then echo '[LINT CHECK]' && npx eslint $FILE 2>&1 | tail -5 || true; fi"
      }
    ],
    "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"
      }
    ]
  }
}

The five hooks are:

  • Audit log: Logs all bash commands to a file.
  • Branch protection: Blocks commits to main/master.
  • File protection: Blocks edits to lock files and migrations.
  • Auto-lint: Runs the linter after file edits.
  • Completion notification: Sends a desktop notification when the agent finishes.
  1. Work with the agent for 30 minutes on real tasks. Do not change your workflow to accommodate the hooks — work as you normally would.

  2. During the session, keep brief notes. For each hook, record:

    • Did it fire? How often?
    • When it fired, was the output helpful or distracting?
    • Did it catch a real problem or prevent a real mistake?
    • Did it slow you down noticeably?
    • Did it confuse the agent (the agent reacting to hook output in unhelpful ways)?
  3. After 30 minutes, stop and evaluate. Create a file called .claude/hook-audit.md with your findings. Use this template:

# Hook Audit — [Date]

## Hooks Tested

### 1. Audit Log (PreToolUse/Bash)
- **Fired**: [how many times]
- **Useful**: [yes/no]
- **Keep**: [yes/no]
- **Reasoning**: [why]

### 2. Branch Protection (PreToolUse/Bash)
- **Fired**: [how many times]
- **Useful**: [yes/no]
- **Keep**: [yes/no]
- **Reasoning**: [why]

### 3. File Protection (PreToolUse/Edit)
- **Fired**: [how many times]
- **Useful**: [yes/no]
- **Keep**: [yes/no]
- **Reasoning**: [why]

### 4. Auto-Lint (PostToolUse/Edit)
- **Fired**: [how many times]
- **Useful**: [yes/no]
- **Keep**: [yes/no]
- **Reasoning**: [why]

### 5. Completion Notification (Stop)
- **Fired**: [how many times]
- **Useful**: [yes/no]
- **Keep**: [yes/no]
- **Reasoning**: [why]

## Summary
- Hooks kept: [list]
- Hooks removed: [list]
- Key lesson: [one sentence]
  1. Based on your evaluation, update .claude/settings.json to only include the hooks you decided to keep.

  2. Review the audit log file (.claude/agent-commands.log). Is there anything surprising in what the agent ran? Did the log reveal commands you would not have noticed otherwise?

Expected Outcome

  • You have hands-on experience with five different hooks in a real workflow.
  • You have a documented evaluation of each hook's practical value.
  • Your settings file contains only hooks that earned their place.
  • You understand that more hooks are not always better — each one has a cost in latency, complexity, and context window usage.
  • You have an informed opinion about which types of hooks provide the most value for your specific workflow.

Hints

  • The most common result is keeping 2-3 hooks out of 5. This is normal and expected. Hooks that sound useful in theory often create friction in practice.
  • The audit log hook is almost always kept — it has near-zero cost and provides valuable forensics. The auto-lint hook is the most polarizing — some find it essential, others find it too noisy.
  • If a hook confuses the agent (the agent starts responding to hook output instead of your requests), that is a strong signal to remove it or reduce its verbosity.
  • Pay attention to latency. If you notice the agent pausing after every edit while the linter runs, measure how much time that adds. If it is 2 seconds, probably worth it. If it is 15 seconds, probably not.
  • The notification hook is often removed by people who work in a single terminal (they already see when the agent finishes) and kept by people who switch to other tasks while the agent works.
  • Your audit document is the real deliverable of this exercise. The evaluation skill — knowing which automation to keep and which to discard — is more valuable than any individual hook.