Hooks and automation
Wire SessionStart hooks to auto-load context. Use PreToolUse to block dangerous commands. Run Claude on cron with approval gates.
Hooks are commands Claude Code runs at specific lifecycle events. Configure once in ~/.claude/settings.json; they fire automatically. Hooks enforce discipline you would otherwise forget: loading STATE.md every session, checking for staged .env files before committing. Setup cost is 10 minutes. The payoff is permanent.
SessionStart fires the moment a session opens; whatever it prints becomes context before your first message:
{
"hooks": {
"SessionStart": [
{
"command": "cat ~/notes/STATE.md ~/notes/MEMORY.md 2>/dev/null || true"
}
]
}
}The || true matters: do not break session start if files are missing. Keep hooks under 50ms and make no network calls at session open. Need fresh API data? Run a cron that writes a local file; load the file in the hook.
Four more hook events:
Stop: fires when a session ends. Use for reminders: "Before you close: update STATE.md."
PreToolUse: fires before Claude calls a tool. Your security enforcement layer: block dangerous commands, log calls for audit trails. Exit 0 allows the call; exit 2 blocks it.
PostToolUse: fires after a tool returns. Run a linter after a file write, check for RLS policies after a SQL migration.
UserPromptSubmit: fires when you hit enter. Inject context or apply prompt injection defenses for external content.
A PreToolUse hook that blocks any Bash call containing rm -rf:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'rm -rf'; then echo 'BLOCKED: rm -rf not allowed' >&2; exit 2; fi"
}
]
}
}The matcher field targets a specific tool. Without it, the hook fires on every tool call. Use matchers to keep hooks fast and targeted.
When a hook is not firing, check in order:
Syntax: valid JSON? Run cat ~/.claude/settings.json | python3 -m json.tool.
File path: test the hook command manually in your terminal.
Exit code: is || true missing?
Speed: over 3 seconds? Move the slow part to a cron.
Two production gotchas. Set an explicit timeout field on every hook so behavior stays predictable. And hooks run in a stripped environment, not your full shell: if a hook needs an environment variable (like a Slack token), source your .env explicitly in the hook command. This is the most common cause of hooks that work in your terminal but silently fail in Claude Code.
Cron-driven Claude Code. The same agent can run on a schedule via claude --headless: a morning briefing at 7 AM posted to Slack, social media monitoring every 4 hours, a deployment health check every 30 minutes.
The critical pattern: approval gates. The cron drafts, you review, the system sends. An autonomous agent that sends outbound messages without review will eventually send something wrong: wrong template, wrong audience, wrong timing. The architecture: agent discovers and drafts, human reviews and releases. Implementation: the agent writes drafts to a Slack channel; nothing goes outbound until a human approves. This scales to dozens of agents without any one going rogue.
Build the hook system in phases:
Phase 1 (weeks 1-2), load hooks: SessionStart loads STATE.md and MEMORY.md; Stop reminds you to update.
Phase 2 (weeks 3-4), guard hooks: PreToolUse hard blocks for irreversible commands (force-push to main, DROP TABLE, rm -rf).
Phase 3 (month 2+), audit hooks: PostToolUse checks on what was actually written: SQL migrations missing RLS policies, new API routes missing rate limiting, secrets in any written file.
PostToolUse is stronger than PreToolUse for audits because it reads the written file content, the ground truth, not just what Claude intended. Build hooks around your actual failure modes: two hooks that solve real problems beat ten theoretical ones.
SessionStart loads context, Stop reminds, PreToolUse blocks dangerous commands (exit 2), PostToolUse audits what was written, and cron plus approval gates turns Claude into a scheduled system with a human on the send button. Start with one hook: cat ~/notes/STATE.md 2>/dev/null || true in SessionStart. Add the next phase each month.