When configuring Claude Code, most teams use CLAUDE.md or skills files to define coding standards. These work well for general guidance, but they share a limitation: the model decides when to read them.
This post explores Claude Code hooks—a mechanism that guarantees your critical rules are injected into context before every file operation.
The Context Loading Problem
Vercel's engineering team ran into this with their AI agent evaluations. In their research comparing skills vs embedded documentation, they found that skills-based retrieval was skipped in 56% of eval cases. The agent simply didn't invoke them.
Passive context (like CLAUDE.md) performed better because it's always present. But even passive context has limits—it's read once at session start, and the model still decides how much attention to give it during each task.
For rules that must be followed every time—security practices, architectural constraints—we need something stronger.
Claude Code Hooks: Guaranteed Execution
Hooks are shell commands that execute automatically at specific points in Claude Code's lifecycle. The key difference: hooks run on every matching tool call, regardless of what the model thinks is relevant.
| Mechanism | When It Loads | Reliability |
|---|---|---|
| CLAUDE.md | Session start | Read once, attention varies |
| Skills | When model invokes | May be skipped by the model |
| Hooks | Every matching tool call | Guaranteed execution |
The PreToolUse hook fires before Claude executes any tool—including Read, Edit and Write. This lets you inject rules directly into context at the moment they matter most.
User: "Add a new endpoint"
↓
Claude prepares Read tool call
↓
PreToolUse hook fires ← Guaranteed, every time
↓
Your rules injected into context
↓
Claude writes code with rules present
Implementation: Package-Aware Rule Injection
Consider a monorepo where different packages have different coding standards. The API package requires Zod validation and parameterized SQL. The UI package mandates React Query over raw useEffect fetching.
Project Structure
project/
├── .claude/
│ ├── settings.json
│ └── hooks/
│ └── inject-rules.py
└── packages/
├── api/
│ └── RULES.md
└── ui/
└── RULES.md
Package Rules
packages/api/RULES.md
# API Package Rules
## Required Patterns
- All endpoints must have input validation using Zod schemas
- Wrap async handlers with error middleware
- Use `logger.info/error` instead of console.log
- All database queries must use parameterized statements
## Naming Conventions
- Controllers: `*.controller.ts`
- Routes: `*.routes.ts`
- Validators: `*.schema.ts`
packages/ui/RULES.md
# UI Package Rules
## Required Patterns
- Use React Query for data fetching (no useEffect + fetch)
- Components must be functional with TypeScript props interface
- Always handle loading and error states
## Naming Conventions
- Components: PascalCase directories with index.tsx
- Hooks: `use*.ts`
- Styles: `*.module.css`
Hook Implementation
The hook reads the file path from stdin, detects the package, and injects the corresponding rules.
.claude/hooks/inject-rules.py
#!/usr/bin/env python3
import sys, json
from pathlib import Path
input_data = json.load(sys.stdin)
file_path = input_data.get("tool_input", {}).get("file_path", "")
PACKAGE_RULES = {
"/packages/api/": "packages/api/RULES.md",
"/packages/ui/": "packages/ui/RULES.md",
}
for pkg_path, rules_path in PACKAGE_RULES.items():
if pkg_path in file_path:
package = pkg_path.strip("/").split("/")[-1]
rules = Path(rules_path).read_text()
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": f"[{package}] rules:\n{rules}"
}
}))
break
Configuration
.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [{
"type": "command",
"command": "python3 .claude/hooks/inject-rules.py"
}]
}
]
}
}
The matcher uses regex—Read|Edit|Write captures both file editing and creation.
Results
With this configuration, requesting a new API endpoint produces code that follows all injected rules:
Request: "Add a createUser endpoint to packages/api/users.controller.ts"
Output:
import { z } from 'zod';
import { logger } from '../shared/logger';
import { asyncHandler } from '../middleware/errorHandler';
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
export const createUser = asyncHandler(async (req, res) => {
const { name, email } = createUserSchema.parse(req.body);
logger.info('Creating user', { email });
const result = await db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[name, email]
);
res.status(201).json(result.rows[0]);
});
| Rule | Applied |
|---|---|
| Zod schema validation | ✓ |
| asyncHandler wrapper | ✓ |
| logger instead of console.log | ✓ |
| Parameterized SQL query | ✓ |
While the same outcome can be achieved with skills, a growing skills library or codebase increases contextual noise and raises the risk that the model overlooks the correct skill.
Advanced Patterns
Compress Rules for Context Efficiency
Every hook injection adds to the context window. As context length increases, LLM performance can drop—a phenomenon called lost in the middle. Compression isn't just about cost; it's about effectiveness.
Borrowing techniques from prompt compression research, here's how to minimize your rules footprint:
Technique 1: Remove Filler Words
Strip articles (a, the), hedging words (should, please, always), and redundant phrases.
| Before | After |
|---|---|
| "All endpoints must have input validation" | "endpoints: validate input" |
| "You should always use parameterized queries" | "use parameterized queries" |
| "Please make sure to wrap handlers" | "wrap handlers" |
Technique 2: Use Symbols and Shorthand
Replace words with universally understood symbols—similar to how minified JS uses short variable names.
| Pattern | Meaning |
|---|---|
→ |
leads to, use, becomes |
!= |
not, avoid, don't use |
✓ |
required, do this |
✗ |
forbidden, never |
* |
wildcard (any) |
$1, $2 |
parameters |
Technique 3: Code-Like Syntax
LLMs parse structured formats efficiently. Use patterns they recognize from code:
# BEFORE
## API Package Rules
### Required Patterns
- All endpoints must have input validation using Zod schemas
- Always wrap async handlers with error middleware
- Use `logger.info/error` instead of console.log for all logging
- All database queries must use parameterized statements to prevent SQL injection
### Naming Conventions
- Controllers should be named: `*.controller.ts`
- Routes should be named: `*.routes.ts`
- Validators should be named: `*.schema.ts`
# AFTER
[api] validate:zod | wrap:asyncHandler | log:logger.* !=console.* | sql:parameterized($1,$2)
naming: *.controller.ts, *.routes.ts, *.schema.ts
Technique 4: Relevance Filtering
Not every rule applies to every file. Filter dynamically in your hook:
# Only inject SQL rules for database-related files
if "repository" in file_path or "query" in file_path:
rules += "\nsql: parameterized queries only"
The compressed format remains fully interpretable by Claude while using a fraction of the context space.
Preserve Through Compaction
Use PreCompact to ensure critical rules survive context compaction:
{
"PreCompact": [{
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/inject-critical-rules.py" }]
}]
}
Debugging
Run claude --debug to see hook execution:
[DEBUG] Executing hooks for PreToolUse:Write
[DEBUG] Hook command completed with status 0: {"hookSpecificOutput":...}
Try It Yourself
- Create
.claude/settings.jsonwith the PreToolUse configuration - Add
.claude/hooks/inject-rules.pywith your package detection logic - Create
RULES.mdfiles in each package directory - Restart Claude Code to load the new hooks
- Edit a file and observe the injected context with
claude --debug
Top comments (0)