AI coding assistants implement code quickly but follow their training defaults, not your project rules. They do not know your team bans git reset --hard, requires GPG-signed commits, or uses a different GitHub org than your username.
Claude Code's PreToolUse hook system intercepts tool calls before execution, blocks dangerous operations, and injects contextual guidance. This article walks through a production implementation consisting of four specialized hooks.
Version Note: The
additionalContextfeature used in this article requires Claude Code v2.1.9 or later (released January 16, 2026). Earlier versions supported blocking hooks but not context injection. This capability originated from feature request #15345.
The Hook Contract
A PreToolUse hook is an executable that receives JSON on stdin and communicates via exit codes and stdout/stderr:
# Input (stdin)
{
"tool_name": "Bash",
"tool_input": {"command": "git reset --hard HEAD~3"}
}
# Exit code 0: Allow (stdout parsed for additionalContext)
# Exit code 2: Block (stderr displayed to user)
The hook output format for allowing with context injection:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"additionalContext": "Your reminder or context here"
}
}
Hook 1: Safety Guards
The first hook blocks destructive operations before they execute.
BLOCKING_RULES: list[SafetyRule] = [
SafetyRule(
pattern=r"git\s+reset\s+--hard",
action="block",
message="BLOCKED: Use `git stash` or `git rebase` instead. "
"`git reset --hard` destroys uncommitted work.",
),
SafetyRule(
pattern=r"--no-gpg-sign",
action="block",
message="BLOCKED: GPG signing is mandatory. Fix GPG issues, don't bypass.",
),
SafetyRule(
pattern=r"git\s+(?:add\s+)?(?:-A|--all|\.\s*$)",
action="block",
message="BLOCKED: Explicitly stage files to avoid committing unwanted files.",
),
]
When asked to commit changes, Claude often runs git add -A to stage everything. In repositories with build artifacts or .env files, this stages files that should never be committed. Forcing explicit staging ensures intentional commits.
Warning rules follow the same pattern but allow the operation while injecting context:
WARNING_RULES: list[SafetyRule] = [
SafetyRule(
pattern=r"git\s+push\s+--force(?!\s*-with-lease)",
action="warn",
message="WARNING: Use `--force-with-lease` for safer force push.",
),
SafetyRule(
pattern=r"\.env",
action="warn",
message="WARNING: This file may contain secrets. Never commit .env files.",
),
]
The execution logic checks blocking rules first, then warnings:
def check_command(command: str) -> tuple[str, str]:
for rule in BLOCKING_RULES:
if rule.matches(command):
return ("block", rule.message)
for rule in WARNING_RULES:
if rule.matches(command):
return ("warn", rule.message)
return ("allow", "")
Hook 2: Context Injection for GitHub API Calls
Repository paths do not always match GitHub organization names. My local checkout lives at ~/dev/AcmeCorp/webapp, but the GitHub owner is AcmeCorp, not my username jdoe. Without intervention, Claude defaults to the wrong owner for API calls.
The context injection hook injects repository metadata whenever Claude uses GitHub MCP tools:
GITHUB_BASE_CONTEXT = """CONTEXT: This repo's GitHub owner is 'AcmeCorp', NOT 'jdoe'.
Always use owner='AcmeCorp' in GitHub API calls.
Repository name: webapp
Default branch: main"""
PROJECT_BOARD_CONTEXT = """
Project board ID: PVT_xxxxxxxxxxxxxxxxxxxx
Epic Priority field ID: PVTF_xxxxxxxxxxxxxxxxxxxxxxx
Status field ID: PVTSSF_xxxxxxxxxxxxxxxxxxxxxxx
Note: gh project item-edit silently fails for Number fields. Use GraphQL mutations instead."""
The hook matcher uses regex to capture all GitHub MCP tools:
{
"matcher": "mcp__github__.*",
"hooks": [{"type": "command", "command": "python3 .../context-injection.py"}]
}
Project-related tools receive additional context about board IDs.
Hook 3: Workflow Rule Enforcement
Some workflow rules cannot be enforced by linters: TDD discipline, worktree usage, commit signing. This hook surfaces reminders at relevant moments without blocking operations:
GPG_REMINDER = WorkflowReminder(
message="GPG REMINDER: Never use `--no-gpg-sign`. If GPG fails, check sandbox mode."
)
TDD_REMINDER = WorkflowReminder(
message="TDD REMINDER: Is there a failing test? Write the RED test first, "
"then write minimal code to make it GREEN."
)
WORKTREE_REMINDER = WorkflowReminder(
message="WORKTREE REMINDER: You appear to be working directly on main. "
"Use `git worktree add .worktrees/issue-XX-description -b issue-XX-description`"
)
Reminders are injected based on tool and file context:
def get_write_edit_reminders(file_path: str) -> list[str]:
reminders = []
# TDD reminder for Go production code
if is_go_production_code(file_path):
reminders.append(TDD_REMINDER.message)
# Worktree reminder if editing production code outside worktree
if not is_in_worktree() and is_production_code_path(file_path):
reminders.append(WORKTREE_REMINDER.message)
return reminders
The worktree check uses an environment variable to detect the current directory:
def is_in_worktree() -> bool:
cwd = os.environ.get("PWD", os.getcwd())
return ".worktrees/" in cwd
Hook 4: Skill Suggestion Engine
Claude Code supports "skills" (reusable instruction sets for specific domains). The skill suggestion hook maps file patterns to relevant skills, prompting Claude to load domain-specific guidance before proceeding.
SKILL_TRIGGERS: list[SkillTrigger] = [
# Test files need test classification + TDD patterns
SkillTrigger(
skills=("classifying-test-sizes", "developing-with-tdd"),
path_contains="_test.go",
),
# Temporal workflows need TDD + Temporal + Go monorepo skills
SkillTrigger(
skills=("developing-with-tdd", "developing-with-temporal", "working-with-go-monorepo"),
path_contains="apps/workers/",
extension=".go",
),
# Database migrations
SkillTrigger(
skills=("working-with-atlas-migrations",),
path_contains="db/migrations/",
),
]
The trigger list uses first-match-wins ordering. Specific patterns (test files, migrations) appear before generic patterns (any Go file in apps/). This ensures a _test.go file triggers test-specific skills rather than generic Go guidance.
The output reminds Claude to invoke the relevant skills:
if skills:
skill_list = ", ".join(f"'{s}'" for s in skills)
output = {
"hookSpecificOutput": {
"additionalContext": (
f"SKILL REMINDER: Before proceeding, invoke these skills: {skill_list}. "
f"They contain patterns, gotchas, and best practices for this file type."
)
}
}
Hook Configuration
The settings.json file orchestrates which hooks run for which tools:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Write|Edit",
"hooks": [{"type": "command", "command": "python3 .../suggest-skills.py"}]
},
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "python3 .../safety-guards.py"},
{"type": "command", "command": "python3 .../workflow-rules.py"}
]
},
{
"matcher": "Write|Edit",
"hooks": [{"type": "command", "command": "python3 .../workflow-rules.py"}]
},
{
"matcher": "mcp__github__.*",
"hooks": [{"type": "command", "command": "python3 .../context-injection.py"}]
}
]
}
}
The matcher field accepts regex patterns. Bash commands receive both safety guards and workflow rules. Write/Edit operations trigger workflow rules and skill suggestions. GitHub MCP tools receive context injection.
Observations
In my workflow, the hooks have changed how Claude interacts with the repository:
- Claude no longer attempts
git reset --hard. The safety hook blocks it and suggests alternatives. - GitHub API calls default to the correct owner. Before the context hook, I corrected the owner manually in roughly one of five API calls.
- The TDD reminder appears on every Go file edit. I cannot quantify compliance, but the reminder makes skipping the red-test step feel deliberate rather than accidental.
- Skill loading happens automatically when I edit workflow files, instead of requiring me to remember to invoke them.
Implementation Considerations
Hooks cannot modify tool inputs, only allow, block, or inject context. Complex validation requiring multiple file reads will slow down every tool call. Hooks see individual tool calls, not conversation context.
Error handling defaults to permissive: JSON parse failures result in exit code 0 (allow) rather than blocking operations. This prevents a malformed hook from breaking the development workflow entirely.
The complete implementation consists of four Python files totaling under 400 lines. Each hook has a single responsibility, making them straightforward to test and modify. Adding new concerns means adding new hooks, keeping each hook focused.
Top comments (0)