What Actually Anchors an AI Coding Agent
Part 1: Hooks. The gap between "Claude can build software" and "Claude builds software the way you'd build it" is wider than most people realize
Where This Started
I’ve been using Claude Code the way most people do - open terminal, describe what I want, let it build. It works. But it’s inconsistent in ways that compound quietly. The problem is that the model starts every session cold, and none of the context carries over unless you’ve explicitly wired it somewhere.
Claude Code has infrastructure to fix this: CLAUDE.md files, hooks, a full lifecycle. I built a toy project to learn each mechanism hands-on.
The Simplest Thing That Works: CLAUDE.md
Before any hooks or automation, there’s CLAUDE.md. A file Claude reads automatically at the start of every session.
The mental model I settled on: it’s the thing you’d say to a new contractor before they opened a single file. You write it once, it applies forever without you repeating yourself.
There are three scopes. Global ~/.claude/CLAUDE.md) applies to every project you open. Put things here that are true about you as a developer regardless of what you’re building. Project-level <project>/CLAUDE.md) applies only inside that directory - architecture decisions, how to run things, what not to touch. Subdirectory-level <project>/src/module/CLAUDE.md) applies when Claude is working inside that module - useful for monorepos where each package has its own rules. Claude walks up the directory tree and loads all of them.
Here’s my global one:
# Global Preferences
## Communication
- Be brief. No summaries of what you just did.
- Include technical details that matter; cut everything else.
- When uncertain, say so.
## Working Style
- Tell me where to make changes; don’t make them unless I explicitly ask.
- Ask before any irreversible action.
## Code Style
- Python 3.11+, uv for package management.
- Type hints on all function signatures.
- Prefer simple solutions. No over-engineering.
- No comments unless the why is genuinely non-obvious.
## Hard Rules
- Never commit or push to git without my explicit confirmation.
- Never install packages without telling me first.
- Never delete files without my confirmation.And the project-level one:
# toy-github-health
## Running the project
- Run: `uv run main.py`
- Tests: `uv run pytest`
## Architecture
- Entry point: main.py
- All GitHub API calls go through github_client.py — no direct API calls in business logic
## Rules
- Never edit uv.lock manually
- Keep functions small and single-purposeDoes it work? Mostly. I watched Claude reference my CLAUDE.md explicitly when I asked it to make an initial git commit - it flagged that .claude/ contained sensitive config and asked whether to include it or update .gitignore first. That’s the file working. But CLAUDE.md is influence, not enforcement. A sufficiently confident agent will override it, especially in a long session where the original instructions have drifted far up the context window. For enforcement, you need hooks.
Hooks: What They Are and What They Actually Do
Hooks are shell commands that Claude Code runs at specific points in its lifecycle. Claude pipes a JSON payload to your script via stdin. Your exit code tells Claude what to do next:
- Exit 0 - allow, continue
- Exit 2 - block the action, show your stdout as the reason
- Any other non-zero - warning shown, Claude continues
They live in .claude/settings.json:
{
“hooks”: {
“PreToolUse”: [
{
“matcher”: “Write”,
“hooks”: [
{ “type”: “command”, “command”: “.claude/hooks/your_hook.sh” }
]
}
]
}
}There are 5 hook events. PreToolUse fires before any tool call. PostToolUse fires after. Stop fires when Claude finishes a turn. SubagentStop fires when a sub-agent finishes. Notification fires when Claude sends you a system notification.
PreToolUse and PostToolUse take a matcher field - the tool name: Write, Edit, Bash, or * for everything. The others fire unconditionally.
Multiple hooks on the same matcher run in order. If any exits 2, the chain stops - subsequent hooks don’t run and neither does the tool call.
That’s the theory. Here’s what I actually found building with it.
What I Built
Lint Gate - PreToolUse / Write + Edit
Blocks Claude from writing Python code that fails ruff. For Write, the file doesn’t exist on disk yet - lint the content from the payload, not the filesystem. The Write tool payload key is file_path; Edit uses path.
#!/bin/bash
input=$(cat)
file=$(echo “$input” | python3 -c “
import sys,json; d=json.load(sys.stdin); ti=d.get(’tool_input’,{})
print(ti.get(’file_path’,’‘) or ti.get(’path’,’‘))
“ 2>/dev/null)
if [[ “$file” != *.py ]]; then exit 0; fi
tool=$(echo “$input” | python3 -c “import sys,json; print(json.load(sys.stdin).get(’tool_name’,’‘))”)
content=$(echo “$input” | python3 -c “import sys,json; print(json.load(sys.stdin).get(’tool_input’,{}).get(’content’,’‘))” 2>/dev/null)
if [[ “$tool” == “Write” && -n “$content” ]]; then
echo “$content” | uv run ruff check --stdin-filename “$file” - 2>&1
else
uv run ruff check “$file” 2>&1
fi
if [ $? -ne 0 ]; then
echo “Ruff lint failed on $file — fix errors before writing”
exit 2
fi
exit 0{ “matcher”: “Write”, “hooks”: [{ “type”: “command”, “command”: “.claude/hooks/pre_edit_lint.sh” }] }Bash Safety Gate - PreToolUse / Bash
Blocks dangerous shell commands before Claude runs them. The Bash payload key is command.
#!/bin/bash
input=$(cat)
command=$(echo “$input” | python3 -c “
import sys,json; print(json.load(sys.stdin).get(’tool_input’,{}).get(’command’,’‘))
“ 2>/dev/null)
dangerous_patterns=(”rm -rf” “git push” “git commit” “drop table” “chmod -R 777”)
for pattern in “${dangerous_patterns[@]}”; do
if echo “$command” | grep -qi “$pattern”; then
echo “Blocked: ‘$pattern’ requires explicit user confirmation”
exit 2
fi
done
exit 0{ “matcher”: “Bash”, “hooks”: [{ “type”: “command”, “command”: “.claude/hooks/pre_bash_safety.sh” }] }CLAUDE.md says “never commit without asking.” The bash hook enforces it. One can be argued with by a confident agent. The other can’t.
Activity Log - PostToolUse / Write + Edit
Appends a timestamped entry every time Claude writes or edits a Python file. If PreToolUse blocked the write, this never fires - PostToolUse only runs when the tool call completes.
#!/bin/bash
input=$(cat)
file=$(echo “$input” | python3 -c “
import sys,json; d=json.load(sys.stdin); ti=d.get(’tool_input’,{})
print(ti.get(’file_path’,’‘) or ti.get(’path’,’‘))
“ 2>/dev/null)
if [[ “$file” != *.py ]]; then exit 0; fi
echo “$(date ‘+%Y-%m-%d %H:%M:%S’) WROTE $file” >> /absolute/path/.claude/agent_activity.log
exit 0
“PostToolUse”: [{ “matcher”: “Write”, “hooks”: [{ “type”: “command”, “command”: “.claude/hooks/post_edit_log.sh” }] }]Use absolute paths in hooks. Relative paths depend on Claude’s working directory, which isn’t guaranteed to be your project root.
Codebase Lint Scan - Stop
Scans the full codebase with ruff at the end of every turn. If any file has errors, blocks Claude’s turn from ending.
Stop exit 2 is different from PreToolUse exit 2: Claude gets one turn to read your hook’s output and respond - it can fix the problem before the session ends. That makes it more useful than a silent block, but it creates a feedback loop: Claude’s response triggers Stop again, which exits 2 again. Guard against this with stop_hook_active:
#!/bin/bash
input=$(cat)
stop_hook_active=$(echo “$input” | python3 -c “
import sys,json; print(json.load(sys.stdin).get(’stop_hook_active’, False))
“ 2>/dev/null)
if [[ “$stop_hook_active” == “True” ]]; then exit 0; fi
result=$(uv run ruff check . --exclude .venv --quiet 2>&1)
if [ -n “$result” ]; then
echo “Lint errors in codebase:”
echo “$result”
exit 2
fi
echo “$(date ‘+%Y-%m-%d %H:%M:%S’) Turn complete — codebase clean” >> /absolute/path/.claude/agent_activity.log
exit 0Stop hooks have no matcher. Wired unconditionally:
“Stop”: [{ “hooks”: [{ “type”: “command”, “command”: “.claude/hooks/on_stop.sh” }] }]Three Hooks I Made Up
Beyond what the docs suggest, I designed three hooks for patterns that aren’t in any example I’ve seen but that solve real problems with AI agents specifically.
File Size Guard
Blocks Claude from writing Python files over 150 lines. Forces it to split into modules before writing.
line_count=$(echo “$content” | wc -l | tr -d ‘ ‘)
if [ “$line_count” -gt 150 ]; then
echo “File size guard: $file has $line_count lines (limit: 150). Split into smaller modules.”
exit 2
fiWhy this matters specifically for agents: Claude optimizes for solving the immediate problem. It will write a 400-line file if that’s the most direct path to a working solution. It’s not being lazy - it just doesn’t have a stake in the long-term maintainability of your codebase. This hook encodes your architecture preferences as a hard constraint, not a preference you’d have to catch in review.
Test Enforcement
Stop hook. After each turn, check if any new Python module was written without a matching test_*.py file. Block the turn if tests are missing.
missing=()
for pyfile in *.py; do
[[ “$pyfile” == test_*.py ]] && continue
[[ “$pyfile” == “main.py” ]] && continue
if [ ! -f “test_${pyfile}” ]; then missing+=(”$pyfile”); fi
done
if [ ${#missing[@]} -gt 0 ]; then
echo “Test enforcement: no test file for: ${missing[*]}”
echo “Create test_<filename>.py before finishing.”
exit 2
fi
“Write tests” is the instruction most commonly ignored in any CLAUDE.md. You can say it a hundred times. This makes it non-negotiable - Claude cannot finish a turn if it wrote a module without a test file. The stop_hook_active guard is required here too, for the same feedback loop reason.
Dependency Audit
PreToolUse on Write targeting pyproject.toml. When Claude tries to add a new dependency, check it against an approved allowlist. Block anything not on the list.
APPROVED=(”requests” “ruff” “pytest” “python-dotenv” “httpx” “pydantic”)
# extract deps from pyproject.toml content, check each against APPROVED
# exit 2 if any dep is not in the listThe full implementation parses the dependency list from the file content using a quick Python regex and checks each package name case-insensitively against the allowlist.
This one exists because AI agents will add whatever package solves the immediate problem - they have no awareness of your security policy, your approved vendor list, or the fact that your company has a process for adding new third-party dependencies. This hook encodes that policy as a gate at the point where the package enters the project.
Wired under PreToolUse with matche Write - same entry as the lint hook, just an additional command in the array. Multiple hooks on the same matcher run in order:
{
“matcher”: “Write”,
“hooks”: [
{ “type”: “command”, “command”: “.claude/hooks/pre_edit_lint.sh” },
{ “type”: “command”, “command”: “.claude/hooks/pre_file_size_guard.sh” },
{ “type”: “command”, “command”: “.claude/hooks/pre_dependency_audit.sh” }
]
}Agent-Based Hooks
Everything above uses shell logic to make decisions - pattern matching, file checks, line counts. There’s another category: hooks that spawn a full Claude sub-agent to make the decision instead.
I used this for security review. When a file being written contains API keys, tokens, auth patterns, or credential-related code, spawn a sub-agent to review it and return a verdict:
if ! echo “$content” | grep -qiE “(api_key|token|secret|password|auth|bearer|credential)”; then
exit 0
fi
review=$(echo “$content” | claude -p “Review this Python code for security issues only.
Look for: hardcoded secrets, exposed API keys, insecure auth patterns.
Reply with PASS or BLOCK followed by one sentence explaining why.” 2>/dev/null)
if echo “$review” | grep -q “^BLOCK”; then
echo “Security review blocked this write:”
echo “$review”
exit 2
fiWithout any of this, Claude Code gives you a capable but unanchored agent. It starts cold, follows your instructions in the moment, and will take irreversible actions if you’re not paying attention. The CLAUDE.md helps - I watched Claude reference it unprompted when it was about to do something risky. But it can be overridden by a confident agent. The hooks can’t.

