Making Claude Code Talk to You with Hooks

March 30, 2026

I spend a lot of time with Claude Code running in the background. I'll kick off a task, switch to a browser tab or Slack, and completely forget about it. Five minutes later I check back and realize Claude has been waiting for my input the whole time. Or worse, it finished ages ago and I've been sitting around for no reason.

Claude Code has a hooks system that solves this nicely. You can wire up scripts that run at specific points in Claude's lifecycle. I use mine to make my Mac literally speak to me when Claude needs attention or finishes a task. The best part: on macOS, you don't need to install anything. The say command is built right into the OS.

What are hooks?

Hooks are shell commands that Claude Code runs automatically at specific moments. You configure them in ~/.claude/settings.json and they receive JSON data on stdin with context about what's happening.

There are six hook types:

  • PreToolUse runs before Claude executes a tool (like reading a file or running a command)
  • PostToolUse runs after a tool finishes
  • Notification fires when Claude needs your attention
  • Stop fires when Claude finishes its turn
  • SubagentStop fires when a subagent (spawned by the Agent tool) finishes
  • UserPromptSubmit runs when you submit a prompt, before Claude sees it

Each hook script communicates back through exit codes:

  • Exit 0 means everything is fine, continue normally
  • Exit 2 blocks the action and shows an error message to Claude (useful for PreToolUse guards)

UV makes this painless

All my hooks are Python scripts, and I run them with UV. UV has a feature called inline script metadata that makes one-off scripts incredibly easy. You put your dependencies and Python version right in the file:

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///

The shebang line (#!/usr/bin/env -S uv run --script) tells the system to run this script through UV. UV reads the inline metadata block, handles the Python version and any dependencies, and runs the script. No virtual environment setup, no requirements.txt, no pip install. You just write your script and it works.

For these notification hooks, we don't even need any dependencies since say is a system command and we're only using Python's standard library. But the UV shebang is still useful because it handles Python version management for you.

In settings.json, you invoke each hook with:

json
"command": "uv run ~/.claude/hooks/your_hook.py"

The Notification hook: "Hey, I need you"

The Notification hook fires when Claude wants your attention. This is the one that saves me from leaving Claude hanging in another tab.

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
 
import argparse
import json
import sys
import subprocess
 
 
def main():
    try:
        parser = argparse.ArgumentParser()
        parser.add_argument("--notify", action="store_true",
                            help="Enable spoken notifications")
        args = parser.parse_args()
 
        input_data = json.loads(sys.stdin.read())
 
        # Only speak if --notify flag is passed
        # Skip the generic waiting message, it fires too often
        if (
            args.notify
            and input_data.get("message") != "Claude is waiting for your input"
        ):
            subprocess.run(
                ["say", "Your agent needs your input"],
                capture_output=True,
                timeout=10,
            )
 
        sys.exit(0)
 
    except Exception:
        sys.exit(0)
 
 
if __name__ == "__main__":
    main()

A few things worth noting. The --notify flag acts as an on/off switch for the speech. You keep it in the settings.json command (notification.py --notify) and can remove it any time to silence notifications without deleting the hook. This is handy because the hook can still do other things (like logging) even when speech is off.

I also skip the generic "Claude is waiting for your input" message because it fires on every single turn and gets noisy fast. The more specific notifications, like when Claude asks a question or hits a blocker, are the ones actually worth hearing about.

The timeout=10 on the subprocess call is a safety net. If say hangs for some reason, we don't want it blocking Claude forever. And wrapping everything in a try/except with sys.exit(0) means the hook fails silently rather than interrupting Claude's work. Hooks should be invisible when they break.

The Stop hook: "I'm done"

The Stop hook fires when Claude finishes a turn. This is the one that tells me I can stop doom-scrolling and go check Claude's output.

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
 
import argparse
import json
import os
import sys
import subprocess
import random
 
 
def main():
    try:
        parser = argparse.ArgumentParser()
        parser.add_argument("--chat", action="store_true",
                            help="Save transcript to logs/chat.json")
        args = parser.parse_args()
 
        input_data = json.loads(sys.stdin.read())
 
        # Save the conversation transcript if --chat is passed
        if args.chat and "transcript_path" in input_data:
            transcript_path = input_data["transcript_path"]
            if os.path.exists(transcript_path):
                chat_data = []
                with open(transcript_path, "r") as f:
                    for line in f:
                        line = line.strip()
                        if line:
                            try:
                                chat_data.append(json.loads(line))
                            except json.JSONDecodeError:
                                pass
 
                log_dir = os.path.join(os.getcwd(), "logs")
                os.makedirs(log_dir, exist_ok=True)
                with open(os.path.join(log_dir, "chat.json"), "w") as f:
                    json.dump(chat_data, f, indent=2)
 
        messages = [
            "Work complete!",
            "All done!",
            "Task finished!",
            "Job complete!",
            "Ready for next task!",
        ]
 
        subprocess.run(
            ["say", random.choice(messages)],
            capture_output=True,
            timeout=10,
        )
 
        sys.exit(0)
 
    except Exception:
        sys.exit(0)
 
 
if __name__ == "__main__":
    main()

I randomize the completion message so it doesn't get monotonous. You could also get creative with the say command's voice options. Running say -v '?' in your terminal lists all available voices. Try say -v Samantha "All done" or say -v Daniel "Task complete" for something different.

The --chat flag is a nice bonus. When enabled, the hook reads the session transcript (a JSONL file that Claude provides in input_data["transcript_path"]) and saves it as a formatted JSON file in your logs/ directory. Handy for reviewing what happened in a session after the fact.

The SubagentStop hook

This one fires when a subagent finishes its work. Claude Code can spawn subagents for parallel tasks, and each one triggers this hook when it completes.

I actually have speech disabled on this one. When Claude spawns several agents in parallel, hearing "Subagent complete" five times in a row gets old fast. I use it purely for logging instead. But if you want the announcement, here's what it would look like:

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
 
import json
import sys
import subprocess
 
 
def main():
    try:
        input_data = json.loads(sys.stdin.read())
 
        # Uncomment the next line if you want spoken notifications
        # subprocess.run(["say", "Subagent complete"], capture_output=True, timeout=10)
 
        sys.exit(0)
 
    except Exception:
        sys.exit(0)
 
 
if __name__ == "__main__":
    main()

For most workflows, keeping this one silent and relying on the main Stop hook for the final "all done" announcement is the better experience.

Logging with PreToolUse, PostToolUse, and UserPromptSubmit

Not every hook needs to talk. The other three hook types are great for logging and auditing what Claude does in your project.

Here's a minimal logging hook that works for any of them:

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
 
import json
import sys
from pathlib import Path
 
 
def main():
    try:
        input_data = json.loads(sys.stdin.read())
 
        log_dir = Path.cwd() / "logs"
        log_dir.mkdir(parents=True, exist_ok=True)
        log_path = log_dir / "hook_events.json"
 
        if log_path.exists():
            try:
                log_data = json.loads(log_path.read_text())
            except (json.JSONDecodeError, ValueError):
                log_data = []
        else:
            log_data = []
 
        log_data.append(input_data)
 
        log_path.write_text(json.dumps(log_data, indent=2))
 
        sys.exit(0)
 
    except Exception:
        sys.exit(0)
 
 
if __name__ == "__main__":
    main()

This dumps every hook event into a JSON file in your project's logs/ directory. It's useful for understanding what tools Claude is calling, how often, and in what order. Make sure to add logs/ to your .gitignore.

The UserPromptSubmit hook is interesting because it runs before Claude even sees your prompt. I run mine with --log-only so it just records prompts without blocking anything. But you could add a --validate flag to check prompts against blocked patterns and exit with code 2 to reject them. The input data includes session_id and prompt fields, so you have everything you need for filtering.

Safety guards with PreToolUse

Since PreToolUse runs before every tool call and can block actions with exit code 2, it's a natural place for safety guards. Here's a stripped-down example that blocks dangerous rm commands and access to .env files:

python
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
 
import json
import sys
import re
 
 
def main():
    try:
        input_data = json.loads(sys.stdin.read())
 
        tool_name = input_data.get("tool_name", "")
        tool_input = input_data.get("tool_input", {})
 
        if tool_name == "Bash":
            command = tool_input.get("command", "")
            normalized = " ".join(command.lower().split())
 
            # Block rm -rf and variations
            if re.search(r"\brm\s+.*-[a-z]*r[a-z]*f", normalized):
                print("BLOCKED: Dangerous rm command", file=sys.stderr)
                sys.exit(2)
 
        # Block reading .env files
        if tool_name in ("Read", "Edit", "Write"):
            file_path = tool_input.get("file_path", "")
            if ".env" in file_path and not file_path.endswith(".env.sample"):
                print("BLOCKED: .env file access", file=sys.stderr)
                sys.exit(2)
 
        sys.exit(0)
 
    except Exception:
        sys.exit(0)
 
 
if __name__ == "__main__":
    main()

When a hook exits with code 2, the message printed to stderr shows up in Claude's context. So Claude knows the action was blocked and why, and can adjust its approach.

Wiring it all up in settings.json

All hooks are configured in ~/.claude/settings.json under the hooks key. Here's what the full configuration looks like:

json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/pre_tool_use.py"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/post_tool_use.py"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/notification.py --notify"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/stop.py --chat"
          }
        ]
      }
    ],
    "SubagentStop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/subagent_stop.py"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "uv run ~/.claude/hooks/user_prompt_submit.py --log-only"
          }
        ]
      }
    ]
  }
}

The matcher field is an empty string here, which means the hook runs for all events of that type. You can set it to a specific tool name (like "Bash" or "Read") to narrow it down. For example, if you only wanted to log Bash commands in PreToolUse, you'd set "matcher": "Bash".

That's it

The whole setup is just a handful of Python scripts in ~/.claude/hooks/ and some JSON config. No notification services, no API keys, no third-party software. Just macOS doing what macOS already knows how to do.

I've been running this for a while now and it's one of those small quality-of-life things that makes a real difference. Instead of compulsively checking back on Claude every thirty seconds, I just wait for my Mac to tell me when something needs my attention.