{"slug": "python-utility-package-for-building-claude-code-hooks", "title": "Python utility package for building Claude Code hooks", "summary": "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.", "body_md": "A Python utility package for building [Claude Code hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) with minimal boilerplate.\n\nClaude Code hooks are custom scripts that run at specific points during Claude Code's execution. They allow you to:\n\n**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)\n\nBuilding Claude Code hooks involves repetitive boilerplate:\n\n- Parsing JSON from stdin\n- Validating input structure\n- Formatting responses in the correct schema\n- Handling errors gracefully\n\n`claude-hook-utils`\n\nhandles all of this, letting you focus on your validation logic.\n\n**One Pattern**- Extend`HookHandler`\n\n, override the hooks you need**Type 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\n\n```\npip install claude-hook-utils\nbash\n#!/usr/bin/env python3\n\"\"\"Validate that Data classes have TypeScript annotation.\"\"\"\n\nfrom claude_hook_utils import HookHandler, PreToolUseInput, PreToolUseResponse\n\nclass DataClassValidator(HookHandler):\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        # Skip if not a Data class file\n        if not input.file_path_matches('**/app/Data/**/*.php'):\n            return None\n\n        # Check for required annotation\n        if input.content and '#[TypeScript()]' not in input.content:\n            return PreToolUseResponse.deny(\n                \"Data classes must have #[TypeScript()] annotation for type generation\"\n            )\n\n        return PreToolUseResponse.allow()\n\nif __name__ == \"__main__\":\n    DataClassValidator().run()\n```\n\nConfigure in `.claude/settings.json`\n\n:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Write|Edit\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"python3 /path/to/data_class_validator.py\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n| Hook Type | When It Runs | Use Cases |\n|---|---|---|\n`PreToolUse` |\nBefore a tool executes | Validate file paths, check content, block dangerous operations |\n`PostToolUse` |\nAfter a tool completes | Log results, trigger follow-up actions, collect metrics |\n`UserPromptSubmit` |\nWhen user submits a prompt | Validate prompts, add context, enforce policies |\n`SessionStart` |\nWhen a Claude Code session begins | Initialize state, set environment variables |\n\nExtend this class and override the hooks you need:\n\n``` python\nfrom claude_hook_utils import HookHandler\n\nclass MyHandler(HookHandler):\n    def __init__(self):\n        super().__init__()\n        # Add any shared state here\n        self._cache: dict = {}\n\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        \"\"\"Called before tool execution. Return None to skip.\"\"\"\n        return None\n\n    def post_tool_use(self, input: PostToolUseInput) -> PostToolUseResponse | None:\n        \"\"\"Called after tool execution. Return None to skip.\"\"\"\n        return None\n\n    def user_prompt_submit(self, input: UserPromptSubmitInput) -> UserPromptSubmitResponse | None:\n        \"\"\"Called when user submits a prompt. Return None to skip.\"\"\"\n        return None\n\n    def session_start(self, input: SessionStartInput) -> SessionStartResponse | None:\n        \"\"\"Called when session starts. Return None to skip.\"\"\"\n        return None\n\nif __name__ == \"__main__\":\n    MyHandler().run()\n```\n\nInput for PreToolUse hooks:\n\n```\n@dataclass\nclass PreToolUseInput:\n    # Common fields\n    session_id: str\n    cwd: str\n    hook_event_name: str  # Always \"PreToolUse\"\n\n    # PreToolUse-specific\n    tool_name: str        # \"Write\", \"Edit\", \"Bash\", etc.\n    tool_input: dict      # Tool-specific parameters\n    tool_use_id: str\n\n    # Helper methods\n    def file_path_matches(self, *globs: str) -> bool:\n        \"\"\"Check if tool_input.file_path matches any glob pattern.\"\"\"\n\n    def file_path_excludes(self, *globs: str) -> bool:\n        \"\"\"Check if tool_input.file_path does NOT match any glob pattern.\"\"\"\n\n    # Convenience properties\n    @property\n    def file_path(self) -> str | None:\n        \"\"\"Get file_path from tool_input (for Write/Edit/Read tools).\"\"\"\n\n    @property\n    def content(self) -> str | None:\n        \"\"\"Get content from tool_input (for Write tool).\"\"\"\n\n    @property\n    def command(self) -> str | None:\n        \"\"\"Get command from tool_input (for Bash tool).\"\"\"\n```\n\nResponse builder for PreToolUse hooks:\n\n``` python\nclass PreToolUseResponse:\n    @staticmethod\n    def allow(reason: str | None = None) -> PreToolUseResponse:\n        \"\"\"Allow the tool to execute.\"\"\"\n\n    @staticmethod\n    def deny(reason: str) -> PreToolUseResponse:\n        \"\"\"Block the tool. Reason is shown to Claude as feedback.\"\"\"\n\n    @staticmethod\n    def ask(reason: str) -> PreToolUseResponse:\n        \"\"\"Request user confirmation before proceeding.\"\"\"\n\n    def with_updated_input(self, **updates) -> PreToolUseResponse:\n        \"\"\"Modify tool_input before execution (only valid with allow).\"\"\"\n```\n\nJSONL-based logging for easy debugging. Logs are organized by namespace (plugin name).\n\n``` python\nfrom claude_hook_utils import HookHandler, HookLogger\n\nclass MyHandler(HookHandler):\n    def __init__(self):\n        # Logs to .claude/logs/my-plugin/hooks.jsonl\n        super().__init__(\n            logger=HookLogger.create_default(\"MyHandler\", namespace=\"my-plugin\")\n        )\n\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        # Session ID is automatically added from input\n        self.logger.info(\"Checking file\", file_path=input.file_path)\n        # ... validation logic ...\n        self.logger.decision(\"allow\", reason=\"Validation passed\")\n        return PreToolUseResponse.allow()\n```\n\n**Log format (JSONL - one JSON object per line):**\n\n```\n{\"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\"}\n{\"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\"}\n```\n\n**Configuration:**\n\n- Default location:\n`{cwd}/.claude/logs/{namespace}/hooks.jsonl`\n\n- Without namespace:\n`{cwd}/.claude/logs/hooks.jsonl`\n\n- Override directory with\n`CLAUDE_HOOK_LOG_DIR`\n\nenv var - Override namespace with\n`CLAUDE_HOOK_LOG_NAMESPACE`\n\nenv var - Session ID is automatically extracted from hook input\n\n``` python\nclass VueValidator(HookHandler):\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        if not input.file_path_matches('**/*.vue'):\n            return None\n\n        content = input.content or ''\n\n        # Check tag order: <script> before <template> before <style>\n        script_pos = content.find('<script')\n        template_pos = content.find('<template')\n        style_pos = content.find('<style')\n\n        if script_pos > template_pos or template_pos > style_pos:\n            return PreToolUseResponse.deny(\n                \"Vue components must have tags in order: <script>, <template>, <style>\"\n            )\n\n        # Check for setup lang=\"ts\"\n        if '<script setup lang=\"ts\">' not in content:\n            return PreToolUseResponse.deny(\n                \"Vue components must use <script setup lang=\\\"ts\\\">\"\n            )\n\n        return PreToolUseResponse.allow()\npython\nclass ControllerValidator(HookHandler):\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        if not input.file_path_matches('**/*Controller.php'):\n            return None\n\n        # Controllers must be in app/Http/Controllers/\n        if not input.file_path_matches('**/app/Http/Controllers/**/*.php'):\n            return PreToolUseResponse.deny(\n                f\"Controllers must be in app/Http/Controllers/. \"\n                f\"Found: {input.file_path}\"\n            )\n\n        return PreToolUseResponse.allow()\npython\nclass NoFormRequestValidator(HookHandler):\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        if not input.file_path_matches('**/*Controller.php'):\n            return None\n\n        content = input.content or ''\n\n        if 'FormRequest' in content:\n            return PreToolUseResponse.deny(\n                \"Do not use FormRequest classes. Use Data classes instead. \"\n                \"See: app/Data/ for examples.\"\n            )\n\n        return PreToolUseResponse.allow()\npython\nclass FileTracker(HookHandler):\n    def __init__(self):\n        super().__init__()\n        self._pending_writes: set[str] = set()\n\n    def pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n        if input.tool_name == 'Write' and input.file_path:\n            self._pending_writes.add(input.file_path)\n            self.logger.info(f\"Tracking write: {input.file_path}\")\n        return PreToolUseResponse.allow()\n\n    def post_tool_use(self, input: PostToolUseInput) -> PostToolUseResponse | None:\n        if input.tool_name == 'Write' and input.file_path:\n            self._pending_writes.discard(input.file_path)\n            self.logger.info(f\"Write completed: {input.file_path}\")\n        return None\n```\n\nThis package generates responses in the official `hookSpecificOutput`\n\nformat:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\",\n    \"permissionDecisionReason\": \"Your reason here\"\n  }\n}\n```\n\n| Decision | Effect |\n|---|---|\n`allow` |\nTool executes immediately, reason shown to user |\n`deny` |\nTool blocked, reason shown to Claude (so it can adapt) |\n`ask` |\nUser confirmation dialog shown |\n\nUse `with_updated_input()`\n\nto modify parameters before execution:\n\n``` php\ndef pre_tool_use(self, input: PreToolUseInput) -> PreToolUseResponse | None:\n    # Auto-correct a common mistake\n    if input.file_path and '/data/' in input.file_path:\n        corrected = input.file_path.replace('/data/', '/Data/')\n        return PreToolUseResponse.allow(\"Auto-corrected path\").with_updated_input(\n            file_path=corrected\n        )\n    return PreToolUseResponse.allow()\n```\n\nThe package handles errors gracefully:\n\n**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)\n\nThis \"fail open\" approach ensures your hooks don't block Claude Code if something goes wrong.\n\nClaude Code provides these environment variables to hooks:\n\n| Variable | Description |\n|---|---|\n`CLAUDE_PROJECT_DIR` |\nAbsolute path to project root |\n`CLAUDE_CODE_REMOTE` |\n`\"true\"` if running in web environment |\n\nThis package uses:\n\n| Variable | Description |\n|---|---|\n`CLAUDE_HOOK_LOG_DIR` |\nOverride default log directory (default: `.claude/logs/{namespace}/` ) |\n`CLAUDE_HOOK_LOG_NAMESPACE` |\nOverride log namespace/subdirectory |\n\nAccess via `input.cwd`\n\nor `os.environ`\n\n.\n\nTo add support for a new hook type:\n\n- Create input dataclass in\n`inputs/`\n\n- Create response class in\n`responses/`\n\n- Add handler method to\n`HookHandler`\n\n- Add dispatch case in\n`HookHandler._dispatch()`\n\nSee existing implementations for patterns to follow.\n\nMIT", "url": "https://wpnews.pro/news/python-utility-package-for-building-claude-code-hooks", "canonical_source": "https://github.com/RasmusGodske/claude-hook-utils", "published_at": "2026-05-29 04:18:34+00:00", "updated_at": "2026-05-29 04:42:50.776552+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "artificial-intelligence", "large-language-models", "ai-products"], "entities": ["Claude Code", "Anthropic", "Python", "claude-hook-utils"], "alternates": {"html": "https://wpnews.pro/news/python-utility-package-for-building-claude-code-hooks", "markdown": "https://wpnews.pro/news/python-utility-package-for-building-claude-code-hooks.md", "text": "https://wpnews.pro/news/python-utility-package-for-building-claude-code-hooks.txt", "jsonld": "https://wpnews.pro/news/python-utility-package-for-building-claude-code-hooks.jsonld"}}