The Claude Code hooks system changed how I work — here's what I built
Most developers using Claude Code know about CLAUDE.md — the file that tells the agent how to behave. Fewer know about hooks, and almost nobody is talking about what you can actually build with them.
Hooks are shell scripts that fire at specific lifecycle events: before and after tool calls, at session start, at session end. They're not LLM features — they're just bash scripts. They run on your machine, in your environment, with your credentials. That changes what's possible.
I run Claude Code as a multi-agent system with persistent memory, a Firestore graph, and autonomous cron sessions. Hooks are load-bearing infrastructure in that system. Here's what I built and why.
How hooks work
Hooks live in .claude/hooks/ in your project. Here's the shape of the config (simplified from a fuller production setup):
{
"hooks": {
"PreToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "bash .claude/hooks/safety-guardrail.sh" }] }
],
"PostToolUse": [
{ "matcher": "*", "hooks": [{ "type": "command", "command": "bash .claude/hooks/mid-session-changelog.sh" }] }
],
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "bash .claude/hooks/session-start.sh" }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "bash .claude/hooks/session-end.sh" }] }
]
}
}
The hook receives context via environment variables:
-
CLAUDE_TOOL_NAME— which tool is being called -
CLAUDE_TOOL_INPUT— the JSON input to that tool
Exit code controls behavior:
-
exit 0— allow it -
exit 2— block it (stderr message shown as reason)
That's it. Simple, composable, runs anywhere bash runs.
Hook 1: A safety guardrail that actually enforces write boundaries
The first thing I built was a PreToolUse hook that blocks writes outside my vault. Not because I was worried about Claude doing something malicious — because I was worried about bugs.
Path expansion, stale context, a confused tool call. These happen. I wanted architectural enforcement, not just instructions in CLAUDE.md.
The hook intercepts Write, Edit, and Bash tool calls and validates that the target path is inside allowed directories. For Bash, it also blocks specific command patterns:
# Block rm -rf with dangerous targets
if echo "$cmd_lower" | grep -qE 'rm[[:space:]]+(-[a-z]*r[a-z]*f|--recursive)[[:space:]]+(/|~|/home|\.\.)'; then
block "Detected 'rm -rf' targeting root, home, or parent directory."
fi
# Block any rm/del that contains tilde (shell expansion risk)
if echo "$cmd" | grep -qE '(rm|del|Remove-Item|rmdir)\b.*~'; then
block "Detected delete command with tilde (~) — shell expansion risk."
fi
The block() function just writes to stderr and exits 2:
block() {
echo "SAFETY GUARDRAIL BLOCKED: $1" >&2
exit 2
}
What I learned: the important failures aren't dramatic. They're a confused path, a ~ that expands wrong, a rm that targets .. instead of the subfolder. The guardrail has caught each of these in real operation. Not frequently — but when it catches one, it earns its existence for the year.
Hook 2: Session start that injects fresh context
Every session, I want Claude to start with current vault state: open threads, recent journal entries, active projects, vitals. Not stale context from the last time the session state file was manually updated — fresh, auto-generated context.
The session-start hook regenerates this before Claude even sees the first message:
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-D:/My_Docs/Scripting_Projects/IDAPIXL}"
VAULT_PATH="$PROJECT_ROOT/IdapixlVault"
STATE_FILE="$VAULT_PATH/System/session-state.md"
PULSE_SCRIPT="$VAULT_PATH/System/Cron/vault-pulse.sh"
# Regenerate context (fast mode: skip slow index rebuild)
if [[ -f "$PULSE_SCRIPT" ]]; then
bash "$PULSE_SCRIPT" --fast 2>/dev/null || echo "[session-start] WARNING: vault-pulse.sh failed, using stale state" >&2
fi
# Inject the freshly generated state
if [[ -f "$STATE_FILE" ]]; then
echo "## Vault Pulse (auto-injected)"
cat "$STATE_FILE"
fi
# Inject current time (always fresh, even if pulse failed)
echo "## Current Time"
echo "$(date '+%Y-%m-%d %H:%M %Z')"
Whatever this script outputs to stdout becomes part of the conversation context. Claude reads it the way it reads any context — it just shows up as system information at the start of the session.
The --fast flag skips rebuilding the semantic index (which is slow) but still regenerates the markdown state file with fresh timestamps, recent files, and current thread state. Total overhead: about 3 seconds per session start.
Hook 3: Session end that extracts observations
This one does the most work.
When a session ends, I want the key observations from the conversation persisted to Firestore — not as a raw transcript, but as semantic memories that future sessions can query. The stop hook fires, reads the session transcript from stdin, and sends it to a Python extractor that calls Gemini Flash to identify and store meaningful observations.
# session-end.sh
EXTRACTOR="$VAULT_PATH/System/Cron/extract-observations.py"
if [[ ! -f "$EXTRACTOR" ]]; then
exit 0
fi
# Read hook JSON from stdin, pipe to Python extractor
# Errors logged to stderr but never block session exit
"$PYTHON" "$EXTRACTOR" 2>&1 || true
The extractor gets the full conversation via stdin, distills the observations worth keeping, and stores them with embeddings. This is how the memory system accumulates — not through manual note-taking, but from every session automatically.
I wrote exit 0 at the end regardless of the extractor's success. A memory system failure should never prevent the session from closing cleanly.
Hook 4: Pre-write recall — reading before writing
The problem: I write a journal entry, and somewhere in the vault there's a relevant earlier observation I'd want to reference. But I only know it exists after I've already written.
The solution: a PreToolUse hook that fires before Write or Edit on journal and mind files, queries the semantic similarity API with the content I'm about to write, and surfaces related memories as conversation context.
# Only fire for vault content files
case "$FILE_PATH" in
*Journal/*|*Mind/*|*Workshop/*|*Projects/*)
;; # continue
*)
exit 0
;;
esac
The hook queries a Cloud Run endpoint that does vector similarity search over stored observations. Results above 0.65 cosine similarity surface as a short list before the write happens. Exit is always 0 — this hook informs, never blocks.
The effect: less redundancy in the vault, more connection between entries, and a gradually compounding semantic layer that makes older content findable in context.
What hooks are actually good for
After running these in production for several months, here's what I'd say:
Use PreToolUse for hard rules. Anything you want enforced regardless of what the agent believes or what instructions it was given. Safety boundaries, path restrictions, command blocklists. The model can't reason its way around an exit 2.
Use SessionStart for context injection. Don't rely on the model reading state files on its own — auto-inject current state so every session starts from a known position. This matters most for agents running autonomous cron sessions where there's no human to orient them.
Use Stop for persistence. Conversations are ephemeral; hooks that fire on exit are your bridge to durable state. Extract observations, update state files, trigger syncs. Whatever you need to not lose.
Keep hooks simple and fast. A hook that fails should fail gracefully (exit 0, log to stderr) rather than blocking the agent. The agent's work is usually more important than the hook's side effect.
The full setup
My complete hooks configuration — including the safety guardrail, session start/end scripts, social voice gate, and expert context injector — is packaged in the Claude Code Config Bundle. It includes the CLAUDE.md templates, the .claude/ folder structure for multi-agent setups, and a guide explaining what each piece does and why.
The hooks aren't theoretical — they're the exact files running in my production setup, adapted for general use. Available at idapixl.gumroad.com/l/auskbu.
If you want to see the vault architecture these hooks live inside — the Firestore memory graph, the cron sessions, the autonomous agent loop — that's documented at r/idapixl. The products are evidence the architecture works. The architecture is the interesting part.
Top comments (0)