# Hooks | Claude for Real Work

> Making the harness do something every time, whether the model cooperates or not. A rule that redirects rather than just refusing.

Source: https://karakabakov.com/courses/claude-for-real-work/hooks/

---

[&larr; Claude for Real Work](https://karakabakov.com/courses/claude-for-real-work/)

# Hooks

Lesson 23 of 28 · 12 min · Updated August 21, 2026

A hook is a shell command that Claude Code runs at a fixed point in the lifecycle, and it runs whether or not the model would have chosen to. Everything so far has been persuasion. `CLAUDE.md` asks. A skill asks in more detail. A plan asks in advance. All of it shapes what Claude tries to do, and none of it is a guarantee.

A `PreToolUse` hook can refuse a tool call outright. That is the difference, and it is the only mechanism in this course that is enforcement rather than instruction.

## A rule stockroom already has

`data/seed.json` is generated by `scripts/seed.mjs`. The README says so, in the “Known problems” section, in the polite voice of a README:

> `data/seed.json` is generated by `scripts/seed.mjs` and committed. Regenerate it rather than editing it by hand.

A hand edit is not wrong so much as futile: the next person to run the generator silently throws it away. This is exactly the shape of rule that a README is bad at and a hook is good at.

## Write it

`.claude/hooks/protect-seed.sh`:

```
#!/bin/bash
# Blocks edits to data/seed.json. It is generated by scripts/seed.mjs and a hand edit
# is lost the next time anybody regenerates it.
path=$(jq -r '.tool_input.file_path // ""')

case "$path" in
  */data/seed.json)
    jq -n '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "data/seed.json is generated. Edit scripts/seed.mjs and run `node scripts/seed.mjs` instead."
      }
    }'
    ;;
  *)
    exit 0
    ;;
esac
```

Make it executable, then wire it up in `.claude/settings.json`:

```
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-seed.sh"
          }
        ]
      }
    ]
  }
}
```

Three levels of nesting, which is the part everyone gets wrong the first time. The event, then a list of matcher groups, then a list of handlers inside each group.

The hook gets the whole tool call as JSON on stdin: `tool_name`, `tool_input`, `cwd`, `session_id`, `permission_mode` and more. Here it only needs `tool_input.file_path`.

## What happens, and why it is better than blocking

Ask for the edit:

```
Change the unitCost of BOLT-M8 to 45 in data/seed.json.
```

What comes back:

> Done! Changed the unitCost of BOLT-M8 from 42 to 45 in `scripts/seed.mjs` and regenerated `data/seed.json`.

It did not fail. It did the right thing instead, because `permissionDecisionReason` is shown to Claude and that reason said what to do. A denial that only says “no” spends a turn; one that says “edit this other file and run that command” spends a turn and gets you the outcome.

Write your reasons as instructions. It is the highest-value sentence in the whole hook.

To prove the hook is what did it, take the alternative away:

```
Use the Edit tool to change "unitCost": 42 to 45 in data/seed.json. Do not touch any
other file and do not run any script.
```

> I cannot make this change. The error indicates that `data/seed.json` is a generated file. The system requires that I edit the source script `scripts/seed.mjs` and run `node scripts/seed.mjs` to update it. However, you explicitly instructed me not to touch any other file, which makes it impossible.

That is the hook talking, quoted back at you.

## The events

There are about thirty. These are the ones you will use:

| Event | When it fires | What it is for |
| --- | --- | --- |
| PreToolUse | Before a tool runs | The only one that can refuse. |
| PostToolUse | After a tool succeeds | Formatters, linters, “run the tests after every edit to src/”. |
| UserPromptSubmit | Before Claude sees your prompt | Can add context or refuse it. |
| SessionStart | When a session begins | Its stdout is visible to Claude, so this is where you inject something that changes per day: a ticket number, a branch, a deploy status. |
| Stop | When Claude finishes a turn | Exit 2 makes it keep going, which is how you build “do not stop while tests are failing”. |

`matcher` filters within an event. For tool events it matches the tool name: `Bash`, `Edit|Write`, `mcp__.*__write.*` if it contains regex characters, `*` for everything.

## Exit codes

Two matter.

**Exit 0** means the hook is happy. If it printed JSON, the JSON decides what happens next. If it printed anything else, that goes to the debug log and is ignored, except on `UserPromptSubmit` and `SessionStart` where Claude sees it.

**Exit 2** blocks, on events that can block, and your stderr becomes the reason. The script above uses JSON instead, because JSON lets you say `deny` with a reason in one place and `allow` from the same script later.

Any other exit code is a hook that broke. It does not block, and you get an error notice in the transcript rather than silence, which is the correct behaviour and also how you find out your script has a typo.

## When is a hook the wrong tool?

**When you want advice, not enforcement.** A hook that fires on every edit and prints opinions is a hook you will disable in a week.

**When it is slow.** `PostToolUse` runs after every matching call. A hook that takes four seconds turns a ten-edit session into forty seconds of waiting, and the default timeout is ten minutes, so nothing will save you from yourself.

**When the rule needs judgement.** “Block edits to generated files” is a hook. “Block bad architecture” is not; it is a code review, and lesson 27 is about that.

## Debugging one that never fires

In order, because it is nearly always one of the first two:

1. **Is it executable?** `chmod +x`. This is most of them.
2. **Is the JSON shaped right?** Three levels: event, matcher groups, handlers. A handler put directly under the event silently matches nothing.
3. **Does the matcher match?** Tool names are exact and case-sensitive. `bash` is not `Bash`.
4. **Run it by hand.** Hooks read JSON on stdin, so you can test one without Claude Code:

   ```
   echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.sh
   ```

   That should print the deny JSON. If it prints nothing, the bug is in your script and Claude Code was never involved.
5. **`claude --debug`** shows hook execution and any parse errors.

Step 4 is the one worth internalising. A hook is a program that reads JSON and writes JSON, and it is testable like any other program.

> Hooks in a project’s `.claude/settings.json` run commands from a repository you cloned. That is why the workspace trust prompt in lesson 11 exists, and it is worth reading a repository’s `.claude/` directory before you trust it, the same way you would skim a `Makefile`.

## Your turn

Build the seed guard above in your stockroom clone. You need `jq` installed.

Then test it without Claude Code at all:

```
echo '{"tool_input":{"file_path":"data/seed.json"}}' | .claude/hooks/protect-seed.sh
echo '{"tool_input":{"file_path":"src/config.ts"}}'  | .claude/hooks/protect-seed.sh
```

> **Check 1:** the first prints the deny JSON, the second prints nothing and exits 0. If both print nothing, the script is not matching; if both deny, the `case` pattern is too broad and you have just blocked every edit in the repository.

Then in a session:

```
Change the unitCost of BOLT-M8 to 45 in data/seed.json.
```

> **Check 2:** `git status` shows `scripts/seed.mjs` and `data/seed.json` both changed, and the session says it edited the generator and regenerated. It routed around the block because your reason told it how. Now change the reason to just `"Not allowed."`, revert, and ask again: it will refuse or guess. The reason is the feature.

## Recap

A hook is a shell command Claude Code runs at a lifecycle event, and `PreToolUse` is the one that can refuse a tool call. It is enforcement, where `CLAUDE.md` and skills are instruction.

The config nests three deep: event, matcher groups, handlers. The hook reads the tool call as JSON on stdin and answers with JSON on stdout, which makes it testable with `echo` and a pipe.

Write the denial reason as an instruction. “Edit this instead and run that” gets you the outcome; “no” gets you a stopped turn.

Use hooks for rules that are mechanical and absolute. Leave judgement to review.

Next: connecting Claude to something outside your repository, and finding out what you granted it.

[&larr; Previous

## Subagents](https://karakabakov.com/courses/claude-for-real-work/subagents/) [Next &rarr;

## MCP](https://karakabakov.com/courses/claude-for-real-work/mcp/)

[&larr; All lessons](https://karakabakov.com/courses/claude-for-real-work/) [Next lesson &rarr;](https://karakabakov.com/courses/claude-for-real-work/mcp/)
