Hooks: automation on lifecycle events
Lifecycle event hooks, enforcement vs guidance, blocking broad searches, compaction context injection, and the session state snapshot system.
Skills are invoked explicitly: you type a slash command and something happens. Hooks are different. They fire automatically in response to lifecycle events, without you doing anything. A hook runs before a tool executes, after a tool completes, when an agent spawns, when a session ends. You configure them once and they operate silently in the background for every session thereafter.
This is the mechanism for enforcing rules that memory and CLAUDE.md cannot enforce. If you write “never search /mnt/c/repos/ directly” in a CLAUDE.md file, Claude will try to follow it. A hook that blocks the command at the shell level before Claude can execute it makes the rule structural rather than aspirational.
What a Hook Is
A hook is a shell command (or script) registered in settings.json under a lifecycle event name. When the event fires, Claude Code runs the command, passes event data to it via stdin as JSON, and reads the response from stdout. The hook can:
- Allow the action to proceed (default, or explicit)
- Deny the action with a reason that gets surfaced to Claude
- Inject additional context into Claude’s view of the event
Hooks run in the host shell, not inside Claude’s context, not as agents. They are fast, deterministic, and unconditional. Claude cannot reason around them, negotiate with them, or forget to apply them.
Hook Event Types
The events you can attach hooks to:
PreToolUse: fires before any tool executes. Can block the tool call entirely with a denial message. The most powerful event for enforcement.PostToolUse: fires after a tool completes. Useful for observation, counting, side effects. Cannot block retroactively.SubagentStart: fires when an agent spawns. Can inject context into the agent’s session before it starts working.TeammateIdle: fires when a teammate agent goes idle. Can block the idle event and require the agent to do something first.Stop: fires when the main session ends. Useful for cleanup, archiving, or notifications.
Each hook registration has an optional matcher field that filters which tools trigger it. An empty matcher matches all tools. "Glob|Grep|Bash" matches only those three.
Concrete Examples
Blocking Overly Broad Repo Searches
Our global block-root-repo-search.sh hook catches one of the most common agent failure modes: searching /mnt/c/repos/ at the root level.
The repo root contains every codebase the team works on: 70+ repositories, millions of files. A broad rg or find against that path does not complete in any reasonable time. Agents that haven’t been explicitly told where to look will attempt it anyway when they are trying to discover what repos exist or where a file might be.
The hook fires on PreToolUse with a matcher of "Glob|Grep|Bash". It parses the tool input, checks whether the path or command targets /mnt/c/repos/ directly, and denies with a specific message:
Searching /mnt/c/repos/ directly is blocked: too broad and will timeout.Consult planning/repos/repos.json to identify the relevant repository,then target it specifically (e.g., /mnt/c/repos/inspection-workflow/).The denial message is what Claude sees. Instead of watching a search spin out, Claude immediately reads back a directive pointing at the right tool (the repo catalog) and the right pattern (a specific repo path). The hook does not just stop the bad behavior; it redirects toward the correct one.
This is the pattern for enforcement hooks: block the wrong action and explain the right one in the denial message. Claude treats the denial message as instruction, not error.
The source is at ~/.claude/hooks/block-root-repo-search.sh (global, applies to all projects).
Compaction Context Injection
The compaction-context-injector.mjs hook fires on SubagentStart, firing every time an agent is spawned. It reads a base template from .teamwork/compaction-context.md and an optional per-agent override from .teamwork/compaction/{agent-name}.md, then injects the combined content into the agent’s session via additionalContext.
The problem this solves: Claude Code compacts conversation history when context gets long. Compaction is necessary; it keeps sessions from hitting the window limit, but it discards content. Instructions and behavioral rules that were in the conversation history can disappear after compaction. For short-lived agents this does not matter, but for long-running teammates working through multi-step implementation tasks, losing their behavioral rules mid-task causes drift.
The hook bypasses this by injecting the critical instructions through the SubagentStart path rather than into the conversation history. Content injected at agent start through this mechanism is placed in a part of the context that survives compaction. The agent keeps its behavioral rules for its entire lifetime regardless of how many compaction events occur.
What goes into the compaction context: phase gates, communication protocols, constraints, session state file paths, lead contact patterns. Anything the agent must retain across its entire run that it cannot reconstruct from the working directory alone.
The hook is registered in the project settings.json with no matcher (it fires on every agent spawn):
"SubagentStart": [ { "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/compaction-context-injector.mjs\"" } ] }]Session State System
The session state hooks are three scripts working together to solve a specific problem: agent context loss during long-running tasks.
When a teammate agent runs for a long time, compaction events and session interruptions can leave it unable to recover where it left off. The solution is periodic session state snapshots written to a file, so a replacement agent can read the file and pick up without re-doing completed work.
The three hooks:
session-state-counter.mjs (PostToolUse): Increments a counter after every tool call. Every 20 tool calls it writes a snapshot flag. Every 100 calls it writes a checkpoint flag. This is the timer mechanism.
session-state-injector.mjs (PreToolUse): Reads the flag file set by the counter. If a snapshot is due, it injects a message into Claude’s context via additionalContext asking the agent to write a progress snapshot to its session state file. The injection happens through PreToolUse rather than as a direct message, which means the agent sees it before its next action, making it impossible to miss.
session-state-idle-check.mjs (TeammateIdle): Fires when a teammate goes idle. Checks whether a session state file exists and whether it was updated within the last 30 minutes. If not, it blocks the idle event with an exit code of 2 and a message requiring the agent to write its session state before stopping. An agent cannot go idle silently without leaving a breadcrumb.
The three hooks work as a pipeline: counter sets the clock, injector delivers the reminder, idle-check enforces the floor. None of these behaviors could be implemented in CLAUDE.md because they depend on Claude Code lifecycle events and timing, things Claude itself cannot observe.
Writing a Hook
A minimal PreToolUse hook that blocks a specific command pattern:
import { readFileSync } from 'fs';
const input = JSON.parse(readFileSync(0, 'utf8'));const cmd = input.tool_input?.command ?? '';
if (cmd.includes('something-to-block')) { process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Explain what to do instead.' } })); process.exit(0);}
process.exit(0); // allow by defaultFor context injection (no block, just add information):
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', additionalContext: 'Information Claude should see before this tool call.' }}));Registration in settings.json:
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "node \"/absolute/path/to/hook.mjs\"", "timeout": 10 } ] } ] }}Use $CLAUDE_PROJECT_DIR for project-relative paths. Use absolute paths for global hooks.
Hooks vs Skills
The distinction is worth stating directly because the use cases can look similar.
A skill is a prompt loaded into your main session when you invoke it. It relies on Claude following instructions. It is the right tool for workflows you want to initiate explicitly and participate in.
A hook is a shell command that fires automatically at a lifecycle event. It does not rely on Claude at all for enforcement; it runs at the OS level before Claude acts. It is the right tool for rules that must hold unconditionally, behaviors you want to automate without invoking anything, and side effects that need to happen regardless of what Claude is doing.
If you find yourself adding the same instruction to CLAUDE.md three times because Claude keeps forgetting it, that instruction probably belongs in a hook.
Putting It Together
Hooks are the lowest-level mechanism in Claude Code. They sit below skills, below agents, below CLAUDE.md. They run before Claude can decide anything.
The practical value compounds with usage. A few well-placed hooks eliminate entire classes of recurring problems: broad searches that time out, agents that go idle without saving state, critical rules that disappear after compaction. Each hook fires silently, costs nothing at invocation time, and applies uniformly to every session.
The next guide in this series covers persistent context in depth: CLAUDE.md, AGENTS.md, the memory system, and how to structure what Claude knows about your project so new sessions start informed instead of blank.