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