Technology16 min read

How to Automate Your Workflow With Claude Code Hooks

By · Published by Everything Blog

In short

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. Here’s the auto-formatter at work, cleaning up a file the moment Claude edits it: ```python import json import platform import subprocess import sys def notify(title, message):…

Key points

  • When you pair with Claude Code, some steps shouldn’t be left to chance, and Claude Code h…: 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 proj…: 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.
  • Here’s the auto-formatter at work, cleaning up a file the moment Claude edits it: ```pyt…: Here’s the auto-formatter at work, cleaning up a file the moment Claude edits it: ```python import json import platform import subprocess import sys def notify(title, message): system = platform.system() if system == "Darwin":

Sources checked by our analysis pipeline: 1

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.

Take the Quiz: Test your knowledge with our interactive “How to Automate Your Workflow With Claude Code Hooks” quiz. You’ll receive a score upon completion to help you track your learning progress:

Interactive Quiz

How to Automate Your Workflow With Claude Code HooksTest your understanding of Claude Code hooks, including the Stop, PreToolUse, and PostToolUse events and the exit codes that block an action.

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

uv

0.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 project to work in: a pyproject.toml

, a starter main.py

, and a few dotfiles. 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

With the script saved, all that’s left is telling Claude Code when to run it. Create .claude/settings.json

and point the Stop

event at your script:

.claude/settings.json

{

"hooks": {

"Stop": [

{

"hooks": [

{

"type": "command",

"command": "uv run .claude/hooks/notify_desktop.py"

}

]

}

]

}

}

There’s no matcher, so the hook runs on every Stop

.

To confirm the hook is registered, run the /hooks

command inside a session:

❯ /hooks

Hooks

1 hook configured

This menu is read-only. To add or modify hooks, edit

settings.json directly or ask Claude.

The menu shows one hook configured. Now try it out: ask Claude to run a quick task, like List the files in this project.

The moment it finishes, a desktop notification pops up. Keep in mind that Stop

fires after every response, not just once when Claude is done for good.

You’ve made your first improvement: Claude now taps you on the shoulder when it’s done. Next, you’ll make sure it only reaches for the tools your project actually uses.

Step 2: Steer Claude From pip

to uv

In a project that standardizes on uv

, a stray pip install

can quietly pollute your environment and bypass your lockfile. You want Claude to reach for uv

every time, not just when it happens to remember. The PreToolUse

event fires before Claude runs a tool, so a hook can catch a pip

command and steer it back to uv

.

To do that, the hook needs to see what Claude is about to run and decide whether to allow it. That adds two things to the pattern from Step 1: a matcher that targets the Bash

tool, and an exit code that lets the script block the command.

The hook takes the event as JSON on standard input. For a Bash

command, the event carries these key fields:

{

// ...

"hook_event_name": "PreToolUse",

"tool_name": "Bash",

"tool_input": {"command": "pip install requests"},

// ...

}

Your hook loads that JSON, checks the command, and answers with its exit code. Save the script next to your notification script as .claude/hooks/guard_pip.py

:

.claude/hooks/guard_pip.py

import json

import sys

def main():

event = json.load(sys.stdin)

if event.get("tool_name") != "Bash":

return 0

command = event.get("tool_input", {}).get("command", "")

words = command.split()

for i, word in enumerate(words[:-1]):

if word not in {"pip", "pip3"} or words[i + 1] != "install":

continue

if i > 0 and words[i - 1] == "uv":

continue

print("Use 'uv add' instead of pip install.", file=sys.stderr)

return 2

return 0

if __name__ == "__main__":

sys.exit(main())

That exit code is the whole contract. Return 0

, and the action goes ahead. Return 2

, and Claude Code blocks the action and hands your standard-error text back to Claude as feedback. Any other code is a non-blocking error, a distinction that trips up a lot of people: exit 1

looks like it should block, but it doesn’t. Only 2

does.

The part that turns this into a nudge, rather than a dead end, is where your standard-error text goes. On exit 2

, Claude Code doesn’t just print that message for you. Claude receives that text as the reason the command failed, treats it like any other tool error, and adjusts.

Pick an exit code for guard_pip.py

and replay the exchange to see which codes actually stop the command and which one hands your message to Claude:

Add the PreToolUse

hook to .claude/settings.json

. Each event gets its own entry under "hooks"

, so it sits alongside the Stop

hook from Step 1. The // ...

in the snippet stands for that Stop

hook. JSON doesn’t allow comments, so replace those lines with your actual configuration before saving:

.claude/settings.json

{

"hooks": {

"Stop": [

// ...

],

"PreToolUse": [

{

"matcher": "Bash",

"hooks": [

{

"type": "command",

"command": "uv run .claude/hooks/guard_pip.py"

}

]

}

]

}

}

The matcher

here targets Bash

, and you can match several tools at once with a pipe, like "Write|Edit"

.

Now watch the guardrail work. Start Claude Code in your project and ask it to install a package the wrong way. The whole exchange plays out on its own:

❯ Add the requests package by running pip install requests.

● I'll try your pip command first and fall back to uv add if needed.

● Bash(pip install requests)

⎿ Error: PreToolUse:Bash hook error:

[uv run .claude/hooks/guard_pip.py]: Use 'uv add' instead of pip install.

● Bash(uv add requests)

⎿ Resolved 6 packages in 19ms

Checked 5 packages in 0.30ms

● Done. requests is now a project dependency. Your pip install requests

command was blocked by a hook (guard_pip.py) that enforces uv, so I

ran uv add requests instead, adding it to pyproject.toml and uv.lock.

Claude tried pip install requests

, but your PreToolUse

hook exited 2

and handed its message back as feedback. Instead of giving up, Claude read the nudge and ran uv add requests

. Now open pyproject.toml

, and you’ll find the dependency Claude added through uv

, not pip

:

pyproject.toml

dependencies = [

"requests>=2.34.2",

]

Steering Claude back to your tools, instead of just blocking it, is what keeps the guardrail from getting in your way. Exit codes and standard error cover most of what you’ll need, and they’re all that these examples use. Claude Code can also read a structured JSON reply on standard output for finer control, but you can reach for that the day you actually need it.

Step 3: Auto-Format Your Code After Every Edit

Every project has a code style, and you want Claude’s edits to match it without you reformatting by hand. The PostToolUse

event runs after a tool finishes, so it’s the place to clean up whatever Claude just wrote. Here, that means running Ruff on the file Claude edited. A pre-commit hook or a CI job would catch bad formatting eventually, but a PostToolUse

hook fixes it the instant the code lands, so you never review an unformatted diff.

This hook calls ruff

, so install it as a uv

tool first. That puts ruff

on your PATH

, where the hook can find it from any directory:

$ uv tool install ruff

...

Installed 1 executable: ruff

That global install matters because a hook inherits the environment Claude Code was launched with. In particular, Claude Code doesn’t activate your project’s virtual environment for you. Anything the hook calls has to be findable on the inherited PATH

.

This time, the matcher targets two tools, since Claude changes files with both Write

and Edit

. And because a hook can be a plain command rather than a script, you can start with a single line:

.claude/settings.json

{

"hooks": {

"Stop": [

// ...

],

"PreToolUse": [

// ...

],

"PostToolUse": [

{

"matcher": "Write|Edit",

"hooks": [

{

"type": "command",

"command": "ruff check --fix . && ruff format ."

}

]

}

]

}

}

That works, but it reformats the whole project on every edit. When Claude touches one file, you don’t want ruff

walking the entire tree. So trade the one-liner for a script that reads the event and formats only the file Claude changed. The PostToolUse

payload includes that file path:

{

// ...

"hook_event_name": "PostToolUse",

"tool_name": "Edit",

"tool_input": {"file_path": "greet.py"},

// ...

}

A PostToolUse

event also carries the tool’s result, so a hook here could react to what a command printed, though formatting doesn’t need that.

Save this as .claude/hooks/format_with_ruff.py

:

.claude/hooks/format_with_ruff.py

import json

import subprocess

import sys

from pathlib import Path

def main():

event = json.load(sys.stdin)

if event.get("tool_name") not in {"Write", "Edit"}:

return 0

file_path = event.get("tool_input", {}).get("file_path", "")

if not file_path.endswith(".py") or not Path(file_path).exists():

return 0

subprocess.run(["ruff", "check", "--fix", file_path], check=False)

subprocess.run(["ruff", "format", file_path], check=False)

return 0

if __name__ == "__main__":

sys.exit(main())

The script pulls tool_input.file_path

from the event and runs ruff

on just that file, so a single edit no longer reformats your whole project.

Order matters for the two ruff

calls. Run ruff check --fix

before ruff format

: --fix

strips any unused imports first, and then ruff format

tidies up the blank lines they leave behind. Flip the order, and ruff format

spaces out the file before --fix

deletes those imports, so the file comes back with stray blank lines at the top.

Point the hook at the script:

.claude/settings.json

{

"hooks": {

"Stop": [

// ...

],

"PreToolUse": [

// ...

],

"PostToolUse": [

{

"matcher": "Write|Edit",

"hooks": [

{

"type": "command",

"command": "uv run .claude/hooks/format_with_ruff.py"

}

]

}

]

}

}

To see it in action, save a deliberately messy greet.py

in your project:

greet.py

import os, sys

def greet( name ):

msg="hi, "+name

return msg

print( greet("world") )

Then ask Claude for a small change and follow the whole session:

❯ Change the greeting in greet.py from hi to hey.

Read 1 file

● Update(greet.py)

⎿ Added 1 line, removed 1 line

1 import os, sys

2 def greet( name ):

3 - msg="hi, "+name

3 + msg="hey, "+name

4 return msg

5 print( greet("world") )

● Done. The greeting in greet.py now says "hey" instead of "hi". A

formatter hook also ran on the file after my edit, so the spacing was

cleaned up as well.

Claude changed a single line, but as soon as the file was saved, your PostToolUse

hook ran ruff

on it behind the scenes, stripping the unused imports and fixing the spacing. Open greet.py

, and the whole thing is clean:

greet.py

def greet(name):

msg = "hey, " + name

return msg

print(greet("world"))

That’s exactly what the demo at the top showed: Claude’s edit lands, and the file comes back matching your project’s style, with nothing left for you to do.

If you want to get comfortable with Ruff itself before wiring it into more hooks, Real Python’s Modern Python Linting With Ruff video course walks through linting and formatting a project with it.

Troubleshooting Your Claude Code Hooks

Two problems account for most misbehaving hooks:

- The hook didn’t fire: Check for a matcher typo, a wrong event name, or invalid JSON in

.claude/settings.json

, since one stray comma keeps the whole file from loading. Run/hooks

in a session to see which hooks actually loaded. - Blocking does nothing: If a

PreToolUse

hook should stop an action but doesn’t, then check that you’re returning2

and not1

. See the exit-code contract in Step 2.

When neither of those is the problem, start Claude with --debug-file

to write the full log to a known path:

$ claude --debug-file /tmp/hooks-debug.txt

The log is verbose, but the pip

guard from Step 2 leaves a clear trail. Scan for the lines around [DEBUG] Hook

, and you can follow the whole decision to block from top to bottom:

1[DEBUG] Hook output does not start with {, treating as plain text

2[DEBUG] Hook PreToolUse:Bash (PreToolUse) error:

3Use 'uv add' instead of pip install.

4[DEBUG] Hook denied tool use for Bash

5[DEBUG] Bash tool permission denied

Those five lines are the exit-code contract playing out step by step:

- Line 1: Claude Code first checks for a structured JSON reply on standard output. There’s none, so it falls back to the exit code and standard error, which is what your script uses.

- Lines 2 and 3: The hook exited non-zero and printed to standard error. That message is the text Claude receives as feedback.

- Line 4: Exit

2

does its job and blocks the tool call. - Line 5: The

Bash

command never runs.

The full log goes to the file you specified. grep -A2 Hook /tmp/hooks-debug.txt

pulls those lines back out.

Next Steps

Your project now runs three hooks: one tells you when Claude finishes, one keeps it on uv

, and one makes every edit follow your style rules. Each does its job automatically, on every matching event, so you never have to ask.

From here, the same patterns stretch further:

- Block dangerous commands: You could stop destructive deletes like

rm -rf

the same way you steered Claude away frompip

, with aPreToolUse

guard that reads the command and exits2

. - Explore other events:

Stop

,PreToolUse

, andPostToolUse

are three of the dozens of lifecycle events available, so there’s a hook for almost any moment you care about. - Go beyond command hooks: A hook can also be prompt-based or agent-based, HTTP-based, or MCP-tool-based, and you can share your hooks with your team by committing

.claude/settings.json

to the repo.

To go deeper on Claude Code and the tools these hooks lean on, explore the following Real Python resources:

- Claude Code Hooks Cheat Sheet

- Claude Code Cheat Sheet

- How to Write a CLAUDE.md File for Claude Code

- uv vs pip: Managing Python Packages and Dependencies

- Ruff: A Modern Python Linter for Error-Free and Maintainable Code

- The subprocess Module: Wrapping Programs With Python

- Python Coding With AI

Hooks are one piece of a development setup that does the boring work for you, alongside the uv

and Ruff workflows you wired up here. To round out that setup, work through Real Python’s Perfect Your Python Development Setup learning path.

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.

Take the Quiz: Test your knowledge with our interactive “How to Automate Your Workflow With Claude Code Hooks” quiz. You’ll receive a score upon completion to help you track your learning progress:

Interactive Quiz

How to Automate Your Workflow With Claude Code HooksTest your understanding of Claude Code hooks, including the Stop, PreToolUse, and PostToolUse events and the exit codes that block an action.

Original source: realpython.com

Technology