Hooks
Hooks
Hooks are deterministic shell commands or actions that run at specific agent lifecycle points — outside prompt instructions entirely. They are how the harness enforces invariants that can’t be trusted to a prompt.
This is the single most powerful pattern for moving behavior out of the model and into the system.
The core problem
Relying on prompts to remember procedural steps is unreliable. Models behave differently under context pressure. They forget rules from 50 turns ago. They skip steps when in a hurry. They invent new shortcuts. The harness needs guarantees that don’t depend on the model’s discretion.
Hooks are the answer. Procedural steps that MUST happen become hook-driven, not prompt-driven. The model can’t skip a hook because the hook isn’t asking for permission — the system runs it.
Patterns observed in Claude Code
26+ lifecycle hook events
The leaked code contained 26+ distinct hook events. Examples:
SessionStart— once per session, on startup/resume/clear/compactSessionEnd— once per session, on shutdownSubagentStart— when a Task subagent spawnsSubagentStop— when a subagent finishesUserPromptSubmit— every user message, before the LLM sees itPreToolUse— before any tool callPostToolUse— after any tool callStop— when the agent is about to finish a turnPreCompact— before context compaction runsPostCompact— after context compaction completesFileChanged— when an external process modifies a watched fileCwdChanged— when the working directory changes- …and more
Each event has a specific contract: input fields on stdin, expected output format, blocking semantics.
Why so many events: the more lifecycle points the harness can observe, the more behavior it can move out of the prompt. Every hook event is an opportunity to enforce something deterministically.
Hook semantics
Each hook receives JSON on stdin and returns JSON on stdout. The output decides what happens:
{}— proceed normally{"decision": "block", "reason": "..."}— block the operation, inject the reason as a system message{"hookSpecificOutput": {...}}— inject additional context, allow the operation- Non-zero exit code — log error, fail-open (continue as if hook didn’t run)
Blocking is rare. Most hooks inject context or perform side effects. Blocking is reserved for invariant enforcement (e.g., “you can’t Edit outside the freeze boundary”).
Matchers
Hooks register against specific tools or events using regex matchers:
{
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{"type": "command", "command": "..."}]
}
]
}
The matcher decides which tool calls trigger the hook. * matches all; | separates alternatives; Edit|Write|MultiEdit is the typical “all writes” pattern.
Lesson: matchers let one hook serve multiple tools without separate registration. Keep matchers narrow — broad matchers run more often, costing latency on every matched call.
Hook chains
Multiple hooks can register against the same event. They run in declaration order:
{
"Stop": [
{
"hooks": [
{"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-verify.cjs", "timeout": 45},
{"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/another-stop-hook.cjs", "timeout": 30}
]
}
]
}
Both hooks run when the agent tries to stop. Either can block. Both have separate timeouts.
Order matters: if the first hook blocks, the second still runs (Claude Code semantics). If you want strict ordering, encode it in the hook scripts themselves.
Skip hooks during recovery
Critical insight from recovery.md: hooks are skipped when the agent is in error recovery mode. Hooks add tokens; tokens worsen prompt_too_long. The recovery path bypasses the normal hook chain.
Implication: never rely on a hook to enforce invariants during error recovery. The hook won’t run.
Fail-open semantics
Hooks must never break the session. Every hook is wrapped in:
- A try/catch
- Crash logging to
hooks/.logs/hook-log.jsonl - Exit 0 (fail-open) on any unexpected error
Why: a buggy hook should be a degraded-mode session, not a broken one. The user can fix the hook offline; the session continues with reduced enforcement.
Trade-off: fail-open hides hook bugs. Mitigation: log every crash; surface the log location at session start; have a harness-tune skill that audits hook logs.
Hooks vs prompts
When to use which:
| Use a hook when… | Use a prompt when… |
|---|---|
| The behavior MUST happen | The behavior is preferred |
| It’s a yes/no enforcement | It’s a guideline |
| Latency budget allows it | The decision needs LLM judgment |
| The check is deterministic | The check is fuzzy |
| It’s a side effect (logging, telemetry) | It’s a UX nudge |
Rule of thumb: if you’re tempted to write “ALWAYS do X” in a system prompt, you should probably write a hook instead. Prompts get ignored under pressure; hooks don’t.
Hook anatomy
A typical hook script:
#!/usr/bin/env node
// Crash wrapper
try {
const { isHookEnabled } = require('./lib/ck-config-utils.cjs');
// Early exit if disabled in config
if (!isHookEnabled('my-hook')) {
process.exit(0);
}
try {
// Read JSON from stdin
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Make a decision
if (shouldBlock(input)) {
console.log(JSON.stringify({
decision: "block",
reason: "Specific reason here"
}));
process.exit(0);
}
// Or inject context
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
additionalContext: "Reminder text"
}
}));
process.exit(0);
} catch (error) {
console.error('WARN: Hook error, allowing operation -', error.message);
process.exit(0);
}
} catch (e) {
// Outer crash wrapper — minimal deps, logs to file, exits 0
const fs = require('fs');
const p = require('path');
const logDir = p.join(__dirname, '.logs');
if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
fs.appendFileSync(p.join(logDir, 'hook-log.jsonl'),
JSON.stringify({ ts: new Date().toISOString(), hook: __filename, status: 'crash', error: e.message }) + '\n');
process.exit(0);
}
Key elements:
- Crash wrapper — outer try/catch. Always exit 0.
- Disabled check —
isHookEnabled('hook-name')reads.ckconfig.json. Users can disable individual hooks. - Inner try/catch — handles expected errors gracefully.
- JSON I/O — stdin → process → stdout.
- Crash log — minimal deps (just Node builtins). Writes to a JSONL file for auditing.
Anti-patterns
- Hooks that block on success. Hook returns
{"decision": "block"}even when nothing’s wrong. Annoying. - Hooks that fail closed. Bug in the hook → session breaks. Should fail open.
- Hooks that write to stdout for non-JSON output. Pollutes the hook protocol. Use stderr for logs.
- Hooks with no timeout. A hung hook hangs the session. Always set a timeout.
- Hooks that depend on external state. Network, filesystem locks, other processes. Brittle.
- Hooks in critical paths with high latency. A 5-second hook on every PreToolUse kills UX.
- Hooks doing model calls. Adds latency, cost, non-determinism. Use a skill for that.
- No hook log. Crashes are invisible. Bug reports are mysteries.
- Hooks with broad matchers.
matcher: "*"runs on every event. Use narrow matchers. - Prompt-style hooks. Hook’s output is a 200-word “Please consider X…” essay. Hooks should be terse.
- Hooks that ignore the disabled flag. No way to turn off a single hook for debugging.
Takeaways for harness engineering
- Hooks > prompts for invariants. If it MUST happen, hook it. If it should happen, prompt it.
- Fail open. Always. Crash → log → exit 0. The session continues.
- Crash log to JSONL. Auditable, line-delimited, easy to grep.
- Per-hook disable flag.
isHookEnabled('hook-name')reads.ckconfig.json. Users can disable individual hooks for debugging. - Narrow matchers. Don’t run on every tool call when you only need
Edit/Write. - Set timeouts. Default 30s; tune per hook. Hung hooks hang the session.
- Skip hooks during error recovery. Hooks add tokens; recovery often needs fewer.
- Hook output is structured JSON. Stdout = decision; stderr = logs.
- Hooks chain in declaration order. Plan for ordering; don’t assume parallelism.
- Log crash location at session start. Surface the log path so users know where to look.
- Hooks share state via files, not memory. Each hook is a fresh process. Use
/tmpor a state dir.
What this repo does
This repo has 17 hooks across 8 lifecycle events. Full inventory in the main README.md. Categorized:
Context injection (additive — never block)
session-init.cjs(SessionStart) — detect project type, persist to env varsinject-rules.cjs(SessionStart) — loadrules/*.mdinto contextsubagent-init.cjs(SubagentStart) — minimal context for subagents (~200 tokens)token-efficiency-reminder.cjs(UserPromptSubmit) — inject token disciplinedev-rules-reminder.cjs(UserPromptSubmit) — re-inject rules + Plan Contextusage-context-awareness.cjs(UserPromptSubmit + PostToolUse) — track API usagedescriptive-name.cjs(PreToolUse:Write) — file-naming guidanceenforce-doc-rules.cjs(PreToolUse:Edit/Write/MultiEdit ondocs/*.md) — doc convention reminder
Defensive blocking
scout-block.cjs(PreToolUse: file ops) — block reads from.ckignorepathsprivacy-block.cjs(PreToolUse: file ops) — block sensitive files unlessAPPROVED:prefixguard-task.cjs(PreToolUse:Task) — force approval on subagent spawning
Loop detection / verification
loop-detection.cjs(PreToolUse:Edit/Write/MultiEdit) — track per-file edit counts in/tmp, warn after thresholdstop-verify.cjs(Stop) — pre-completion lint + intent-vs-diff check; usesstop_hook_activecircuit breaker
Build feedback
build-sensor.cjs(PostToolUse:Edit/Write/MultiEdit) — auto-run project build, surface only failures
Cleanup
session-cleanup.cjs(SessionEnd) — clean up/tmpstate fromloop-detection.cjs
Patterns this repo demonstrates
- Crash wrapper everywhere. Every
.cjshook has the outer try/catch + JSONL log. isHookEnabledcheck. Users can disable individual hooks via.ckconfig.json.- Lib reuse. Common logic in
hooks/lib/(project-detector, context-builder, scout-checker, privacy-checker, etc.). Hooks are thin wrappers around library functions. - Env var passing.
session-init.cjsdetects expensive properties once and writes them to env vars; downstream hooks read the env vars instead of recomputing. - Consistent language.
.cjs(CommonJS Node) for all hooks — consistent runtime, shared utilities viarequire(). - Narrow matchers. Each hook’s matcher targets exactly the tools it needs.
- Timeouts on slow hooks.
stop-verify.cjsis 45s,usage-context-awareness.cjsis 30s. Defaults are overridden where measured needs require it.
Open problems
- No hook telemetry beyond crash logs. We don’t know which hooks fire most, which add the most latency, which inject the most tokens. A weekly audit would be valuable — maybe a
harness-tuneinvocation. - No automatic hook crash surfacing.
hooks/.logs/hook-log.jsonlexists but isn’t checked at session start. Bugs lurk silently. - No hook ordering enforcement. Multiple hooks on the same event run in declaration order, but the order isn’t documented per event.
- No hook test framework. Each hook is hand-tested. A scripted test runner that pipes mock JSON to each hook and asserts on output would catch regressions.
- Hook scripts are CommonJS only. No ESM. Mostly fine, but limits ergonomics for newer Node features.
- No PreCompact hook. The distribution doesn’t hook into context compaction events yet. A PreCompact hook could enforce “extract before compact” at the harness level.