When you pair with Claude Code, some steps shouldn’t be left to chance, and Claude Code hooks are how you lock them in. You want a heads-up when it finishes a long task, a guarantee that it sticks to your project’s approved tools, and a promise that every edit is formatted to your code standards, without repeating yourself each time. Asking the model to remember all of that works until it doesn’t.
Hooks make these actions deterministic: they fire on the event you choose, every time. A hook is nothing more than a command you register to run when a specific event happens. It doesn’t wait for the model to choose to act, which is what makes it reliable.
In this tutorial, you’ll set up these hooks in a project of your own. Here’s the auto-formatter at work, cleaning up a file the moment Claude edits it:

As you build them, you’ll learn the three parts a hook is made of and the exit-code contract that lets a hook block or redirect what the model is about to do.
Get Your Code: Click here to download the free sample code you’ll use to build the desktop notifier, pip guard, and auto-formatter hooks you wire up in this tutorial.
Prerequisites
To follow along, you should be comfortable with intermediate Python, the terminal, and reading a bit of JSON. You should also have spent some time in Claude Code already.
If it’s new to you, then Real Python’s How to Use Claude Code to Write and Debug Python tutorial walks through the basics, and the Getting Started With Claude Code video course covers the same ground on screen.
You’ll need a few tools on your system:
- Claude Code 2.1.187 or later
- Python 3.12 or later
uv0.11.6 or later- Ruff 0.15.20 or later
If you don’t have uv yet, then Real Python’s guide on installing uv walks you through it. You’ll install Ruff later, when you build the formatting hook.
Step 1: Ping Yourself After Every Claude Response
Start with a small uv-managed project, the kind you’d actually work in. Spin one up with uv:
$ uv init --no-package hooks-demo
Initialized project `hooks-demo` at `/home/you/hooks-demo`
$ cd hooks-demo
$ uv sync
...
That gives you a pyproject.toml, a starter main.py, and a few dotfiles to work in. The uv sync step creates the project’s virtual environment up front, so the hooks can run cleanly from the start.
Now for your first improvement. When you hand Claude a long task and look away, you might miss the moment Claude wraps up and lose a few minutes before you circle back. A hook on the Stop event closes that gap, since Stop fires every time Claude finishes a response. That makes it the right place to send yourself a desktop notification.
Notifications look different on each operating system, so rather than hard-code one command, you’ll point the hook at a small Python script that works everywhere. Create a .claude/hooks/ folder at the root of your project, then save this as .claude/hooks/notify_desktop.py:
.claude/hooks/notify_desktop.py
import json
import platform
import subprocess
import sys
def notify(title, message):
system = platform.system()
if system == "Darwin":
script = f'display notification "{message}" with title "{title}"'
subprocess.run(["osascript", "-e", script], check=False)
elif system == "Linux":
subprocess.run(["notify-send", title, message], check=False)
else:
return False
return True
def main():
json.load(sys.stdin)
try:
notified = notify("Claude Code", "Claude just finished responding")
except FileNotFoundError:
notified = False
if not notified:
print("Claude just finished responding")
return 0
if __name__ == "__main__":
sys.exit(main())
The script reads the event that Claude Code sends on standard input, then picks the right notifier: osascript on macOS and notify-send on Linux. On Windows and other systems, or if the notifier isn’t installed, the hook falls back to a plain-text message so it never interrupts your session. You could also wire up a native Windows toast with a PowerShell module like BurntToast.
Project-level hooks live in .claude/settings.json, and every hook is made of three parts:
- Event: The moment the hook runs, like
Stop - Matcher: An optional filter that limits the hook to certain tools
- Command: The shell command to run