cd /news/ai-tools/python-utility-package-for-building-… · home topics ai-tools article
[ARTICLE · art-17193] src=github.com pub= topic=ai-tools verified=true sentiment=· neutral

Python utility package for building Claude Code hooks

Anthropic released claude-hook-utils, a Python utility package that reduces boilerplate for building Claude Code hooks. The package provides typed dataclasses, a builder pattern for responses, and multi-hook support, allowing developers to validate tool calls, react to results, intercept prompts, and initialize session state with minimal code.

read6 min publishedMay 29, 2026

A Python utility package for building Claude Code hooks with minimal boilerplate.

Claude Code hooks are custom scripts that run at specific points during Claude Code's execution. They allow you to:

Validate tool calls before they execute (PreToolUse)React to tool results after execution (PostToolUse)Intercept user prompts before Claude sees them (UserPromptSubmit)Initialize state when a session starts (SessionStart)

Building Claude Code hooks involves repetitive boilerplate:

  • Parsing JSON from stdin
  • Validating input structure
  • Formatting responses in the correct schema
  • Handling errors gracefully

claude-hook-utils

handles all of this, letting you focus on your validation logic.

One Pattern- ExtendHookHandler

, override the hooks you needType Safety- Typed dataclasses for inputs, builder pattern for responses** Explicit Control**- Helper methods on inputs, but you decide when to skip/allow/deny** Multi-Hook Support**- One Python program can handle multiple hook types** No Heavy Dependencies**- Core package has minimal dependencies; bring your own AI SDK if needed

pip install claude-hook-utils
bash
#!/usr/bin/env python3
"""Validate that Data classes have TypeScript annotation."""

from claude_hook_utils import HookHandler, PreToolUseInput, PreToolUseResponse

class DataClassValidator(HookHandler):
    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        if not input.file_path_matches('**/app/Data/**/*.php'):
            return None

        if input.content and '#[TypeScript()]' not in input.content:
            return PreToolUseResponse.deny(
                "Data classes must have #[TypeScript()] annotation for type generation"
            )

        return PreToolUseResponse.allow()

if __name__ == "__main__":
    DataClassValidator().run()

Configure in .claude/settings.json

:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/data_class_validator.py"
          }
        ]
      }
    ]
  }
}
Hook Type When It Runs Use Cases
PreToolUse
Before a tool executes Validate file paths, check content, block dangerous operations
PostToolUse
After a tool completes Log results, trigger follow-up actions, collect metrics
UserPromptSubmit
When user submits a prompt Validate prompts, add context, enforce policies
SessionStart
When a Claude Code session begins Initialize state, set environment variables

Extend this class and override the hooks you need:

from claude_hook_utils import HookHandler

class MyHandler(HookHandler):
    def __init__(self):
        super().__init__()
        self._cache: dict = {}

    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        """Called before tool execution. Return None to skip."""
        return None

    def post_tool_use(self, input: PostToolUseInput) -> PostToolUseResponse | None:
        """Called after tool execution. Return None to skip."""
        return None

    def user_prompt_submit(self, input: UserPromptSubmitInput) -> UserPromptSubmitResponse | None:
        """Called when user submits a prompt. Return None to skip."""
        return None

    def session_start(self, input: SessionStartInput) -> SessionStartResponse | None:
        """Called when session starts. Return None to skip."""
        return None

if __name__ == "__main__":
    MyHandler().run()

Input for PreToolUse hooks:

@dataclass
class PreToolUseInput:
    session_id: str
    cwd: str
    hook_event_name: str  # Always "PreToolUse"

    tool_name: str        # "Write", "Edit", "Bash", etc.
    tool_input: dict      # Tool-specific parameters
    tool_use_id: str

    def file_path_matches(self, *globs: str) -> bool:
        """Check if tool_input.file_path matches any glob pattern."""

    def file_path_excludes(self, *globs: str) -> bool:
        """Check if tool_input.file_path does NOT match any glob pattern."""

    @property
    def file_path(self) -> str | None:
        """Get file_path from tool_input (for Write/Edit/Read tools)."""

    @property
    def content(self) -> str | None:
        """Get content from tool_input (for Write tool)."""

    @property
    def command(self) -> str | None:
        """Get command from tool_input (for Bash tool)."""

Response builder for PreToolUse hooks:

class PreToolUseResponse:
    @staticmethod
    def allow(reason: str | None = None) -> PreToolUseResponse:
        """Allow the tool to execute."""

    @staticmethod
    def deny(reason: str) -> PreToolUseResponse:
        """Block the tool. Reason is shown to Claude as feedback."""

    @staticmethod
    def ask(reason: str) -> PreToolUseResponse:
        """Request user confirmation before proceeding."""

    def with_updated_input(self, **updates) -> PreToolUseResponse:
        """Modify tool_input before execution (only valid with allow)."""

JSONL-based logging for easy debugging. Logs are organized by namespace (plugin name).

from claude_hook_utils import HookHandler, HookLogger

class MyHandler(HookHandler):
    def __init__(self):
        super().__init__(
            logger=HookLogger.create_default("MyHandler", namespace="my-plugin")
        )

    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        self.logger.info("Checking file", file_path=input.file_path)
        self.logger.decision("allow", reason="Validation passed")
        return PreToolUseResponse.allow()

Log format (JSONL - one JSON object per line):

{"ts": "2025-01-04T10:15:23.456+00:00", "level": "INFO", "hook": "MyHandler", "namespace": "my-plugin", "session": "abc123", "msg": "Checking file", "file_path": "/path/to/file.php"}
{"ts": "2025-01-04T10:15:23.458+00:00", "level": "DECISION", "hook": "MyHandler", "namespace": "my-plugin", "session": "abc123", "msg": "decision=allow", "decision": "allow", "reason": "Validation passed"}

Configuration:

  • Default location: {cwd}/.claude/logs/{namespace}/hooks.jsonl

  • Without namespace: {cwd}/.claude/logs/hooks.jsonl

  • Override directory with CLAUDE_HOOK_LOG_DIR

env var - Override namespace with CLAUDE_HOOK_LOG_NAMESPACE

env var - Session ID is automatically extracted from hook input

class VueValidator(HookHandler):
    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        if not input.file_path_matches('**/*.vue'):
            return None

        content = input.content or ''

        script_pos = content.find('<script')
        template_pos = content.find('<template')
        style_pos = content.find('<style')

        if script_pos > template_pos or template_pos > style_pos:
            return PreToolUseResponse.deny(
                "Vue components must have tags in order: <script>, <template>, <style>"
            )

        if '<script setup lang="ts">' not in content:
            return PreToolUseResponse.deny(
                "Vue components must use <script setup lang=\"ts\">"
            )

        return PreToolUseResponse.allow()
python
class ControllerValidator(HookHandler):
    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        if not input.file_path_matches('**/*Controller.php'):
            return None

        if not input.file_path_matches('**/app/Http/Controllers/**/*.php'):
            return PreToolUseResponse.deny(
                f"Controllers must be in app/Http/Controllers/. "
                f"Found: {input.file_path}"
            )

        return PreToolUseResponse.allow()
python
class NoFormRequestValidator(HookHandler):
    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        if not input.file_path_matches('**/*Controller.php'):
            return None

        content = input.content or ''

        if 'FormRequest' in content:
            return PreToolUseResponse.deny(
                "Do not use FormRequest classes. Use Data classes instead. "
                "See: app/Data/ for examples."
            )

        return PreToolUseResponse.allow()
python
class FileTracker(HookHandler):
    def __init__(self):
        super().__init__()
        self._pending_writes: set[str] = set()

    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
        if input.tool_name == 'Write' and input.file_path:
            self._pending_writes.add(input.file_path)
            self.logger.info(f"Tracking write: {input.file_path}")
        return PreToolUseResponse.allow()

    def post_tool_use(self, input: PostToolUseInput) -> PostToolUseResponse | None:
        if input.tool_name == 'Write' and input.file_path:
            self._pending_writes.discard(input.file_path)
            self.logger.info(f"Write completed: {input.file_path}")
        return None

This package generates responses in the official hookSpecificOutput

format:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "Your reason here"
  }
}
Decision Effect
allow
Tool executes immediately, reason shown to user
deny
Tool blocked, reason shown to Claude (so it can adapt)
ask
User confirmation dialog shown

Use with_updated_input()

to modify parameters before execution:

def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:
    if input.file_path and '/data/' in input.file_path:
        corrected = input.file_path.replace('/data/', '/Data/')
        return PreToolUseResponse.allow("Auto-corrected path").with_updated_input(
            file_path=corrected
        )
    return PreToolUseResponse.allow()

The package handles errors gracefully:

Invalid JSON input: Returns exit 0 (no output = allow)** Unknown hook type**: Returns None (skip)** Exception in handler**: Logged to stderr, returns exit 0 (fail open)

This "fail open" approach ensures your hooks don't block Claude Code if something goes wrong.

Claude Code provides these environment variables to hooks:

Variable Description
CLAUDE_PROJECT_DIR
Absolute path to project root
CLAUDE_CODE_REMOTE
"true" if running in web environment

This package uses:

Variable Description
CLAUDE_HOOK_LOG_DIR
Override default log directory (default: .claude/logs/{namespace}/ )
CLAUDE_HOOK_LOG_NAMESPACE
Override log namespace/subdirectory

Access via input.cwd

or os.environ

.

To add support for a new hook type:

  • Create input dataclass in inputs/

  • Create response class in responses/

  • Add handler method to HookHandler

  • Add dispatch case in HookHandler._dispatch()

See existing implementations for patterns to follow.

MIT

── more in #ai-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/python-utility-packa…] indexed:0 read:6min 2026-05-29 ·