claude-hook-utils

This is a note from my public notes. View the canonical version: claude-hook-utils.

claude-hook-utils is a small Python library for writing Claude Code Hooks as classes. You subclass HookHandler, override the method for the event you care about and return a response object. The library reads the JSON that Claude Code sends on stdin and prints JSON in the exact shape Claude Code expects. Rasmus Godske published it in January 2026 under the MIT License.

The problem it solves

Every command hook does the same plumbing before it does anything useful. Parse stdin. Work out which event fired. Dig the file path or the shell command out of tool_input. Then build an output object with exactly the right field names (hookSpecificOutput, permissionDecision, permissionDecisionReason...). Get one name wrong and your decision is ignored or reported as a hook error. The library moves that plumbing into typed dataclasses and response builders, so a hook file is mostly the rule you want to enforce.

The author built it for a concrete need: keeping Claude in line with Laravel/Inertia/Vue conventions. His claude-liv-conventions plugin (see Claude Code Plugins) uses it for hooks such as FormRequestBlocker, which denies FormRequest classes and points Claude to spatie/laravel-data Data classes, and VueScriptValidator, which requires <script setup lang="ts">. His code review orchestrator, Reldo, names it as the infrastructure for its SubagentStart/SubagentStop handling.

How it works

A hook is a script that ends with MyHandler().run(). run() reads stdin, dispatches on hook_event_name, calls your method and prints the response. Returning None means "no opinion", so the tool call goes through the normal flow.

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()

You register the script like any command hook: in .claude/settings.json under hooks.PreToolUse, with a matcher such as Write|Edit and "command": "python3 /path/to/data_class_validator.py".

Events the code handles (version 0.4.0):

Event Method What you can return
PreToolUse pre_tool_use allow(reason), deny(reason), ask(reason). .with_updated_input(**fields) rewrites the tool arguments (allow only)
PostToolUse post_tool_use acknowledge(), or with_context(text), sent to Claude as additionalContext
SubagentStart subagent_start allow(), which prints nothing
SubagentStop subagent_stop allow() or block(reason). The reason goes back to the subagent so it can fix the issue and retry; stop_hook_active tells you it's a retry

The input classes carry helpers. file_path_matches() and file_path_excludes() take globs with **, and properties such as file_path, content, command, old_string, new_string, url and query pull values out of tool_input. One class can implement several methods, so a single script can serve several events and keep state between calls in the same process.

There's also HookLogger. It writes JSON Lines to {cwd}/.claude/logs/{namespace}/hooks.jsonl; CLAUDE_HOOK_LOG_DIR and CLAUDE_HOOK_LOG_NAMESPACE override the location. Once a logger is attached, the handler logs every invocation and its outcome (allow, deny, skip, block...) with the session id. That alone is worth something, because debugging hooks is usually guesswork.

The deny reason is the interesting part. It goes back to Claude, so a blocked write becomes a correction ("use a Data class instead, see app/Data/") and Claude can adapt instead of retrying blindly. One commenter on the Hacker News thread singled that out.

Install and run

pip install claude-hook-utils. It needs Python 3.10 or later and has zero runtime dependencies.

Another HN commenter objected to the README's approach: install with pip, then run the script in whatever environment happens to be active. Their advice was uv, and the author's own plugin already works that way. Each hook gets its own pyproject.toml declaring claude-hook-utils>=0.3.0 and is launched with cd ${CLAUDE_PLUGIN_ROOT}/hooks/<HookName> && uv run python main.py. Copy that pattern.

Where the README and the code disagree

The source tells a slightly different story than the README:

  • Events. The README and docs/CONTRACTS.md document UserPromptSubmit and SessionStart handlers. The code has never implemented them: no input class and no dispatch case, anywhere in the git history. Any event other than the four above is logged as "Unknown hook event" and ignored. Claude Code itself documents more than 30 events today
  • PostToolUse input. The library reads tool_result and tool_error. The official docs name the field tool_response. So tool_result stays empty, and the succeeded/failed helpers can't tell you much. Failed tool calls fire a separate PostToolUseFailure event, which the library doesn't handle
  • SubagentStart. Claude Code lets this hook inject additionalContext into the subagent. The library's response always prints nothing, so you can't
  • Fail open. The README says exceptions in your handler are logged and turned into exit 0. Only invalid JSON on stdin is caught. An exception in your method crashes the script with exit code 1, which Claude Code treats as a non-blocking error: the action proceeds and the transcript shows a hook error notice. Same outcome, different path. Keep it in mind for policy hooks, because a crash lets the action through

Maturity

Small and young. As of 2026-10-03: 15 commits (all between January 3 and 12, 2026), nothing since, and no issues or pull requests. The latest release is 0.4.0 (2026-01-12), which added SubagentStart and SubagentStop. PyPI has 0.1.0, 0.2.0, 0.2.3, 0.3.0 and 0.4.0; the GitHub tags don't line up exactly (v0.0.1, v0.0.3, v0.2.1...). Version 0.3.0 changed the log format, flagged as a breaking change in the commit. The package classifier says Beta, and the test suite covers the logger only.

The Hacker News submission (2026-05-29, posted by someone other than the author) reached 18 points and 2 comments.

The author

Rasmus Godske. His site, rasmusgodske.com, describes him as "Software Engineer | Selfhost Enthusiast | HomeLab" and has posts on Laravel, TypeScript and Coolify. His recent GitHub repositories are mostly coding-agent tooling: Claude Code convention plugins, a Claude Agent SDK HTTP wrapper and Reldo.

Alternatives

  • cchooks (GowayLee, Python, MIT). create_context() detects the hook type for you, and the README claims 9 hook events including SessionStart and SessionEnd, with an exit-code mode and a JSON mode. Wider coverage than claude-hook-utils
  • claude-hooks-sdk (Beyond Code, PHP, MIT). Laravel-inspired fluent API for building hook responses, installed with Composer
  • claude-hooks (John Lindquist, TypeScript, MIT). npx claude-hooks scaffolds typed hooks that run on Bun. Last push in August 2025
  • claude-code-hooks-mastery (IndyDevDan, Python). A collection of example hooks to learn from rather than a library
  • Claude Agent SDK hooks. If you drive Claude through the Agent SDK, hooks are in-process callback functions; no stdin or stdout involved
  • Claude Code Mods. TypeScript function hooks that run inside Claude Code itself

My take

The design is good. One base class, typed inputs, response builders that can't produce a wrong field name, and deny reasons that reach Claude. For a PreToolUse guardrail on writes and edits, its main use case, it does the job. The whole package is about 1,700 lines, so you can read all of it in an hour.

Beyond that I'd hesitate. It handles four events, the README promises two more that don't exist, the PostToolUse input reads the wrong field, and nothing has moved since January. If you need wider event coverage in Python, look at cchooks first. Or vendor the code and fix it; it's small enough.

References


About Sébastien

Ready to get to the next level?

Found this valuable? Share it with someone who needs it.

Join 6,000+ readers. Get practical systems for knowledge & AI. Free.

Subscribe ✨

Free: Knowledge System Checklist

A clear roadmap to building your own knowledge system. Subscribe and get it straight to your inbox.

6,000+ readers. No spam. Unsubscribe anytime.

Subscribe