
Cc Hooks
- 23 installs
- 416 repo stars
- Updated August 5, 2026
- boshu2/agentops
cc-hooks is a skill for configuring Claude Code hooks (PreToolUse, PostToolUse, Stop, Notification) that run shell commands at lifecycle points.
About
cc-hooks is a skill for configuring Claude Code hooks that fire shell commands at lifecycle events (PreToolUse, PostToolUse, Stop, Notification). A developer uses it to gate or react to tool calls, block actions, or trigger automation inside a session. It is the fold target for the related cc-* loop, subagent, and worktree-isolation skills.
- Configure Claude Code hooks: PreToolUse, PostToolUse, Stop, Notification
- Shell commands that fire at specific points in the agent lifecycle
- Folds in cron-ticks, loop-driver, subagents, and worktree-isolation guidance
Cc Hooks by the numbers
- 23 all-time installs (skills.sh)
- Ranked #10,032 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
cc-hooks capabilities & compatibility
- Capabilities
- cc worktree isolation · cass memory
- Use cases
- orchestration · ci cd
- IDEs
- vscode
What cc-hooks says it does
Configure Claude Code hooks (PreToolUse, PostToolUse, Stop, Notification).
Shell commands that fire at specific points in Claude Code's lifecycle.
npx skills add https://github.com/boshu2/agentops --skill cc-hooksAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23 |
|---|---|
| repo stars | ★ 416 |
| Last updated | August 5, 2026 |
| Repository | boshu2/agentops ↗ |
What it does
Add or audit Claude Code lifecycle hooks that run shell commands at tool events.
When should I use this skill?
You need to configure Claude Code hooks or fold in the cc-* loop, subagent, or worktree-isolation skills.
What you get
Hooks fire shell commands at defined lifecycle events to gate, react to, or block tool use.
- hook configuration in settings.json
By the numbers
- 4 hook events (PreToolUse, PostToolUse, Stop, Notification)
- 4 absorbed skills folded in
Files
Claude Code Hooks
Absorbed skills (ag-s43tg)
- cc-cron-ticks — Scheduling autonomous in-session flywheel ticks with Claude Code cron routines.
- cc-loop-driver — Running a Claude-native control-plane tick loop with worker and separate-validator subagents.
- cc-subagents — Dispatching scoped Claude Code subagents with worktrees, roles, tools, memory, and evidence gates.
- cc-worktree-isolation — Isolating parallel Claude Code workers in separate git worktrees to prevent file collisions.
Shell commands that fire at specific points in Claude Code's lifecycle.
<!-- TOC: Quick Start | Events | Blocking | Writing Hooks | Anti-Patterns | References -->
Quick Start
Add to ~/.claude/settings.json (user) or .claude/settings.json (project):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "my-validator.sh" }
]
}
]
}
}Hook Events
| Event | When | Blocks? | Common Use |
|---|---|---|---|
PreToolUse | Before tool runs | Yes | Block/modify commands |
PostToolUse | After tool succeeds | Feedback | Auto-format, lint |
PermissionRequest | Permission dialog | Yes | Auto-approve/deny |
UserPromptSubmit | Prompt submitted | Yes | Add context, validate |
Stop | Claude finishes | Yes | Force continue |
SessionStart | Session begins | No | Load context, set env |
Notification | Notifications | No | Desktop alerts |
Full schemas: HOOK-EVENTS.md
Matchers
"Bash" → exact match
"Edit|Write" → regex OR
"mcp__.*__write" → MCP tools
"*" or "" → all toolsTools: Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch
Exit Codes
| Code | Effect |
|---|---|
| 0 | Success - JSON parsed from stdout |
| 2 | Block - stderr fed to Claude |
| Other | Non-blocking error |
Blocking a Tool
Simple (exit 2):
echo "Blocked: reason" >&2 && exit 2JSON (exit 0):
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Blocked"}}Decisions: "allow" (auto-approve), "deny" (block), "ask" (show dialog)
Modifying Input
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow",
"updatedInput":{"command":"modified-command"}}}Real-World: DCG + RCH
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[
{"type":"command","command":"dcg"},
{"type":"command","command":"rch"}
]}]}}- DCG: Blocks
git reset --hard,rm -rf,git push --force - RCH: Routes builds to remote workers
Details: DCG-RCH.md
Skill-First Coordination Guard (opt-in)
A copy-paste PreToolUse recipe that nudges agents to load the coordination skill before hand-rolling the `am`/`atm`/`ntm`/`tmux send-keys` CLI. AgentOps 3.0 is hookless — this auto-installs nothing; you opt in per host.
Context-budget doctrine for hooks: hooks are the most powerful enforcement (mechanical, can't be reasoned past) but they pollute context — use sparingly. A hook must be SILENT on the happy path (exit 0, no stdout/stderr), fire ONLY on a real violation (ideally once per session, sentinel-gated), prefer PreToolUse violation-guards over UserPromptSubmit/SessionStart per-turn injectors, and NEVER emit stray stdout on an exit-0 PreToolUse path (it is parsed as JSON and breaks the tool call). Block via exit 2 + stderr.
The recipe ships both scripts verbatim, a precise head-only matcher (so a br create --body "...am/atm/ntm..." never false-fires), the two-matcher opt-in settings.json snippet, and a bats test proving every fire/silent case.
Recipe: SKILL-FIRST-COORDINATION-GUARD.md
Installed-Skill-Edit Guard (opt-in)
A PreToolUse Edit|Write guard that routes an edit of an installed skill copy (*/.claude/skills/**, .codex, .gemini) back to the repo source of truth skills/<name>/. This is a TRUE mistake-token — editing an installed/symlinked copy has no legitimate form (overwritten on install, or symlinks through to the factory checkout). Zero false-positive surface: it matches tool_input.file_path only, so a doc that merely mentions claude/skills in its body never fires. Reversible → it ROUTES (exit 2 + one-line redirect), not hard-blocks. Silent on every other path; fires once per session. Ships INERT — opt-in installer:
scripts/install-installed-skill-edit-guard.sh # user scope; --project for projectRecipe: INSTALLED-SKILL-EDIT-GUARD.md
Value-proof (why this guard survives the hookless teardown)
The keystone guard ships gate-blind per-fire telemetry: on each fire it appends exactly one JSONL line — {ts, session, token_class, path_sha256} — to ${AGENTOPS_HOME:-~/.agentops}/guardrail-telemetry.jsonl (override with AGENTOPS_GUARDRAIL_TELEMETRY). The path is SHA-256 hashed, never raw (privacy); nothing is written on the happy path; the sensor is inert until the guard is installed and fires. The pre-registered methodology — metric = declining fire-ATTEMPT rate over time (a signal the redirect cannot fake, NOT the circular hand-roll rate), minimum N, noise floor, and null-at-small-N is an acceptable outcome — satisfies ADR-0002 l.58 ("test or eval evidence showing positive value"), the criterion whose absence killed 2.x hooks (#511).
Methodology: GUARDRAIL-VALUE-PROOF.md
Writing Your Own Hook
Minimal Python:
#!/usr/bin/env python3
import json, sys
data = json.load(sys.stdin)
cmd = data.get('tool_input', {}).get('command', '')
if 'dangerous' in cmd:
print("Blocked: dangerous", file=sys.stderr)
sys.exit(2)
sys.exit(0) # AllowHook input (stdin):
{"tool_name":"Bash","tool_input":{"command":"npm test"},"session_id":"...","cwd":"..."}Environment Variables
| Variable | Scope | Purpose |
|---|---|---|
CLAUDE_PROJECT_DIR | All | Project root |
CLAUDE_ENV_FILE | SessionStart/Setup | Persist env vars |
Stop Hook (Force Continue)
{"decision":"block","reason":"Tests failing. Fix before stopping."}Critical: Check stop_hook_active to prevent infinite loops.
Anti-Patterns
| Don't | Do |
|---|---|
| Old object format | Array format with matcher |
Unquoted $VAR | "$VAR" |
| Exit 2 with JSON | Exit 2 uses stderr only |
Skip stop_hook_active check | Always check in Stop hooks |
Debugging
claude --debug # Hook execution details
/hooks # View/edit in REPLAbsorbed Skills (skill-prune phase 2 fold-ins)
This skill is the fold target for four retired Claude Code operator skills. Their use-cases route here:
- cc-cron-ticks — scheduling autonomous in-session flywheel ticks with Claude
Code cron routines. Use Claude Code scheduled tasks (cron routines) to fire a recurring tick prompt (e.g. an evolve tick or a bead-queue pull); pair each tick with a Stop hook that verifies evidence landed before the session ends.
- cc-loop-driver — running a Claude-native control-plane tick loop with worker
and separate-validator subagents. One tick = claim a bead, dispatch a worker subagent, then a SEPARATE validator subagent grades the evidence; hooks enforce the gate (PreToolUse blocks out-of-scope writes, Stop blocks close-without-evidence).
- cc-subagents — dispatching scoped Claude Code subagents with worktrees, roles,
tools, memory, and evidence gates. Give each subagent an explicit role prompt, a tool allowlist, and a write scope; never let two subagents share a write surface.
- cc-worktree-isolation — isolating parallel Claude Code workers in
separate git worktrees to prevent file collisions. git worktree add <dir> -b <branch> per worker; workers commit only in their own worktree; the orchestrator merges branches sequentially. File collisions are the #1 swarm failure mode.
References
- HOOK-EVENTS.md - All events with full schemas
- DCG-RCH.md - Production examples (dcg, rch)
- SKILL-FIRST-COORDINATION-GUARD.md - Opt-in coordination skill-first guard + context-budget doctrine
- INSTALLED-SKILL-EDIT-GUARD.md - Opt-in guard routing installed-skill edits to repo skills/ (keystone)
- GUARDRAIL-VALUE-PROOF.md - Pre-registered value-proof methodology + per-fire telemetry contract (ADR-0002 l.58)
- PATTERNS.md - Auto-format, logging, notifications
- JSON-OUTPUT.md - Response schemas
#!/usr/bin/env bash
# installed-skill-edit-guard (PreToolUse / Edit|Write)
# age-workflow-guardrail-hooks-j39.1 — route Edit/Write of an INSTALLED skill copy
# back to the repo source of truth.
#
# The mistake-token: an Edit/Write whose target path is under */.claude/skills/**
# (or .codex/skills, .gemini/skills) has NO legitimate form — those are the
# installed / symlinked copies (overwritten on install; symlinks through to the
# factory checkout). The source of truth is skills/<name>/ in the agentops repo.
#
# Reversible footgun -> ROUTE, not hard-block: exit 2 + a one-line stderr redirect.
#
# Context-budget discipline (hooks are powerful but pollute context — use sparingly):
# - SILENT on the happy path: any other file_path -> exit 0, zero stdout/stderr.
# - Fires its one redirect ONLY on an installed-skill-copy edit, at most ONCE
# per session (sentinel-gated) so it never repeats.
# - NEVER emits stray stdout on an exit-0 PreToolUse path (stdout there is
# parsed as JSON). Block via exit 2 + stderr only.
set -uo pipefail
input="$(cat)"
path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // ""')"
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
# Match ONLY the file_path: an Edit/Write target under an installed skills dir.
# We match the path segment `.claude/skills/` (or .codex/.gemini) anywhere in the
# path so ~, $HOME, and absolute /Users/*/.claude/skills/** all hit. We match the
# file_path field only — a repo doc whose BODY mentions "claude/skills" lands in
# tool_input.content, never file_path, so prose can never fire this guard.
case "$path" in
*/.claude/skills/*|*/.codex/skills/*|*/.gemini/skills/*)
: # installed skill copy -> fire
;;
*)
exit 0 # repo skills/**, any other path -> SILENT happy path
;;
esac
dir="${TMPDIR:-/tmp}/claude-installed-skill-edit-guard"
sentinel="$dir/${sid//\//_}"
[ -f "$sentinel" ] && exit 0 # already redirected this session
mkdir -p "$dir" 2>/dev/null || true
: > "$sentinel" 2>/dev/null || true
# Derive the repo-relative target so the redirect is actionable.
name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)/.*#\2#p')"
[ -n "$name" ] || name="$(printf '%s' "$path" | sed -n 's#.*/\.\(claude\|codex\|gemini\)/skills/\([^/]*\)$#\2#p')"
hint="skills/<name>/"
[ -n "$name" ] && hint="skills/${name}/"
# --- value-proof telemetry (age-workflow-guardrail-hooks-j39.2) -------------
# Emit EXACTLY one gate-BLIND JSONL line per FIRE. The metric is the
# fire-ATTEMPT rate over time (a learning signal the redirect itself cannot
# fake) — see references/GUARDRAIL-VALUE-PROOF.md. PRIVACY: never the raw
# command/path — only a SHA-256 hash of the path. Inert until the guard is
# installed (this code only runs when the guard fires). Best-effort: telemetry
# failure must NEVER change the guard's exit behavior.
emit_telemetry() {
command -v jq >/dev/null 2>&1 || return 0
# Hash the path (privacy): sha256sum / shasum -a 256 / openssl, first available.
local h=""
if command -v sha256sum >/dev/null 2>&1; then
h="$(printf '%s' "$path" | sha256sum | cut -d' ' -f1)"
elif command -v shasum >/dev/null 2>&1; then
h="$(printf '%s' "$path" | shasum -a 256 | cut -d' ' -f1)"
elif command -v openssl >/dev/null 2>&1; then
h="$(printf '%s' "$path" | openssl dgst -sha256 | sed 's/^.*= *//')"
else
return 0 # no hasher -> emit nothing rather than risk leaking the raw path
fi
[ -n "$h" ] || return 0
local tdir="${AGENTOPS_HOME:-${HOME}/.agentops}"
local tfile="${AGENTOPS_GUARDRAIL_TELEMETRY:-${tdir}/guardrail-telemetry.jsonl}"
mkdir -p "$(dirname "$tfile")" 2>/dev/null || return 0
local line
line="$(jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg session "$sid" \
--arg token_class "installed-skill-edit" \
--arg path_sha256 "$h" \
'{ts:$ts, session:$session, token_class:$token_class, path_sha256:$path_sha256}' \
)" || return 0
printf '%s\n' "$line" >> "$tfile" 2>/dev/null || return 0
}
emit_telemetry
cat >&2 <<MSG
⛔ INSTALLED-SKILL EDIT: do not edit installed skill copies.
${path}
is an INSTALLED / symlinked copy — overwritten on install, or symlinked through
to the factory checkout. Editing it is lost work.
→ Edit ${hint} in the agentops repo (the source of truth) instead.
Fires once per session. Re-run your edit against the repo skills/ path.
MSG
exit 2
DCG and RCH: Production Hook Examples
Real-world PreToolUse hooks from production systems.
Combined Configuration
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "dcg" },
{ "type": "command", "command": "rch" }
]
}
]
}
}Both hooks run in parallel on every Bash command.
---
DCG (Destructive Command Guard)
Purpose: Safety hook that blocks dangerous commands before execution.
What DCG Blocks
Git Commands:
git reset --hard- Destroys uncommitted workgit checkout -- <path>- Discards local changesgit restore(without --staged) - Discards changesgit clean -f- Deletes untracked filesgit push --force- Rewrites remote historygit branch -D- Force-deletes branchgit stash drop/clear- Destroys stashes
Filesystem:
rm -rfoutside of /tmp, /var/tmp, $TMPDIR
Additional Packs:
containers.docker- Container destructionkubernetes.kubectl- Cluster operationsdatabases.sql- DROP, TRUNCATE, DELETE without WHEREcloud.terraform- Infrastructure destruction
Installation
# Install via Homebrew
brew install dcg
# Or from source
cargo install destructive_command_guardConfiguration
# Environment variables
DCG_VERBOSE=0-3 # Verbosity (0=quiet, 3=trace)
DCG_QUIET=1 # Suppress non-error output
DCG_NO_COLOR=1 # Disable colors
DCG_FORMAT=text|json|sarif
DCG_CONFIG=/path # Explicit config file
DCG_HOOK_TIMEOUT_MS # Evaluation timeoutHow It Works
1. Receives JSON hook input via stdin 2. Parses the tool_input.command field 3. Evaluates against pattern packs 4. Returns exit 2 with explanation if blocked 5. Returns exit 0 if allowed
Example Output (Blocked)
🛡️ DCG blocked: git reset --hard
This command destroys uncommitted work. Alternatives:
• git stash - Save changes temporarily
• git diff > backup - Export changes first
• git reset --soft - Keep changes staged---
RCH (Remote Compilation Helper)
Purpose: Intercepts build commands and offloads to faster remote workers.
What RCH Intercepts
cargo build,cargo test,cargo checkmake,cmake --buildgo build,go testnpm run build,yarn build- Other configurable patterns
How It Works
┌─────────────────────────────────────────────────────────┐
│ Claude Code │
│ ───────────── │
│ 1. Claude wants: cargo build --release │
│ │ │
│ ▼ │
│ 2. PreToolUse hook fires → RCH receives JSON │
│ │ │
│ ▼ │
│ 3. RCH detects: "This is a cargo command" │
│ │ │
│ ▼ │
│ 4. RCH routes to remote worker via SSH │
│ - Syncs project files │
│ - Executes on fast machine │
│ - Streams output back │
│ │ │
│ ▼ │
│ 5. Returns JSON: permissionDecision: "allow" │
│ with updatedInput containing modified command │
└─────────────────────────────────────────────────────────┘Installation
# Quick start
rch hook install && rch daemon start
# Verify
rch status --workers --jobsCommands
rch hook install # Install PreToolUse hook
rch hook test # Test with sample cargo build
rch daemon start # Start local daemon
rch daemon stop # Stop daemon
rch workers probe # Test worker connectivity
rch workers add # Add new worker
rch config show # Show configuration
rch doctor # Run diagnosticsConfiguration
# Environment variables
RCH_PROFILE=dev|prod|test
RCH_LOG_LEVEL=trace|debug|info|warn|error
RCH_DAEMON_SOCKET=/path/to/socket
RCH_SSH_KEY=/path/to/key
RCH_TRANSFER_ZSTD_LEVEL=1-22Config Precedence
1. Command-line arguments 2. Environment variables 3. Profile defaults (RCH_PROFILE) 4. .env / .rch.env files 5. Project config (.rch/config.toml) 6. User config (~/.config/rch/config.toml) 7. Built-in defaults
---
Hook Interaction
DCG and RCH work together:
1. DCG runs first (parallel, but faster)
- If DCG blocks → command never reaches RCH
- If DCG allows → continues to RCH
2. RCH evaluates
- If build command → intercept and route
- If not build → pass through unchanged
3. Results merged
- Both can modify the command
- Both can add context
- Any block is final
---
Writing Your Own Hook Like DCG/RCH
Minimal Rust Structure
use serde::{Deserialize, Serialize};
use std::io::{self, Read};
#[derive(Deserialize)]
struct HookInput {
tool_name: String,
tool_input: ToolInput,
}
#[derive(Deserialize)]
struct ToolInput {
command: String,
}
#[derive(Serialize)]
struct HookOutput {
#[serde(rename = "hookSpecificOutput")]
hook_specific_output: HookSpecificOutput,
}
#[derive(Serialize)]
struct HookSpecificOutput {
#[serde(rename = "hookEventName")]
hook_event_name: String,
#[serde(rename = "permissionDecision")]
permission_decision: String,
#[serde(rename = "permissionDecisionReason")]
permission_decision_reason: String,
}
fn main() {
let mut input = String::new();
io::stdin().read_to_string(&mut input).unwrap();
let hook_input: HookInput = serde_json::from_str(&input).unwrap();
if should_block(&hook_input.tool_input.command) {
eprintln!("Blocked: dangerous command");
std::process::exit(2);
}
// Allow
std::process::exit(0);
}Minimal Python Structure
#!/usr/bin/env python3
import json
import sys
def main():
input_data = json.load(sys.stdin)
command = input_data.get('tool_input', {}).get('command', '')
if is_dangerous(command):
print("Blocked: dangerous command", file=sys.stderr)
sys.exit(2)
# Allow with modification
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe command",
"updatedInput": {
"command": modify_command(command)
}
}
}
print(json.dumps(output))
sys.exit(0)
if __name__ == '__main__':
main()---
Troubleshooting
DCG Not Blocking
# Check DCG is in path
which dcg
# Test manually
echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | dcg
echo $? # Should be 2RCH Not Intercepting
# Check daemon running
rch daemon status
# Check worker connectivity
rch workers probe --all
# Test hook manually
rch hook testHook Format Error
hooks.PreToolUse: Expected array, but received objectFix: Use new array format:
// Wrong
{"PreToolUse": {"tools": ["Bash"], "hooks": [...]}}
// Correct
{"PreToolUse": [{"matcher": "Bash", "hooks": [...]}]}Guardrail Value-Proof Methodology (pre-registered)
age-workflow-guardrail-hooks-j39.2 · BC6-Orchestration · cc-hooks family
This document is the pre-registered methodology that lets a workflow-guardrail hook earn the "lease on life" ADR-0002 demands. It is written and committed before the measurement is run, so the success criterion and the null-tolerance cannot be retrofitted to whatever the data happens to say.
Status at landing (no overclaim): this ENABLES the ADR-0002 proof — it does
not yet PROVIDE it. The guard ships INERT (opt-in installer); the telemetry
channel collects zero data until it is installed AND N≥30 real fires
accrue. So ADR-0002 l.58 is not cleared at landing — it becomes clearable once
the data exists. (Recorded by the 2026-06-17 recent-commits review.)
Why this exists (the whole point)
AgentOps went hookless (#511) on the finding that hooks "couldn't be proven to have value" — the 2.x A/B eval showed injected context made no difference (aggregate_delta = 0). ADR-0002 (docs/adr/ADR-0002-agentops-3-hookless-cdlc-rearchitecture.md, l.58) therefore requires, for any hook to survive: "test or eval evidence showing positive value." Without that evidence, the installed-skill-edit keystone guard is just another unproven hook awaiting the next teardown. This methodology + the per-fire telemetry it consumes is that evidence pipeline.
The sensor: gate-blind per-fire telemetry
The keystone guard (skills/cc-hooks/hooks/installed-skill-edit-guard.sh) emits exactly one JSONL line per FIRE to ${AGENTOPS_HOME:-~/.agentops}/guardrail-telemetry.jsonl (override with AGENTOPS_GUARDRAIL_TELEMETRY):
{"ts":"2026-06-16T18:30:00Z","session":"<session_id>","token_class":"installed-skill-edit","path_sha256":"<64-hex>"}ts— UTC ISO-8601, second resolution.session— the Claudesession_id(the unit the attempt-rate is computed per).token_class— which mistake-token / guard fired (installed-skill-edit).path_sha256— a SHA-256 hash of the edited path, never the raw path.
Privacy invariant: the raw command/path is NEVER persisted — only the hash. The hash is one-way; it lets us count distinct edited targets and detect repeats without ever logging what the agent was editing. Asserted in tests/scripts/installed-skill-edit-telemetry.bats.
Inert by default: the emission code only runs when the guard fires, and the guard ships INERT (AgentOps 3.0 hookless default; opt-in installer only). On a machine where the guard is not installed, zero lines are ever written. On a machine where it IS installed, the happy path (any non-installed-skill edit) writes nothing.
Gate-blind: the sensor records the attempt, not the outcome of the redirect. It cannot see whether the agent subsequently "did the right thing" — by design (see the Goodhart note below).
The metric: fire-ATTEMPT rate over time
Define, per session s:
fires(s)= count of telemetry lines withtoken_class = installed-skill-edit
emitted during session s.
The success signal is a declining fire-attempt rate across sessions — i.e. a downward trend in fires(s) (or fires(s) normalized by session length / edit volume) as s advances in time. The interpretation: once a guard reliably interrupts a mistake-token, the agent (and the operator tuning prompts/ skills around it) stops attempting the mistake. That is a learning signal that the gate's own redirect cannot fabricate — the redirect fires after the attempt is already counted; lowering the count requires the attempt itself to stop happening, which the hook cannot do by counting.
Why NOT the hand-roll / "did they comply" rate (the Goodhart trap)
The original design measured the hand-roll rate with the guard on vs off. That was rejected (pre-mortem finding #3) as circular / Goodhart:
- The gate's redirect lowers the post-redirect hand-roll rate by construction —
the guard exists to do exactly that, so "the rate went down" proves nothing.
- The counterfactual ("would the agent have complied without the guard?") is
unobservable in a single timeline.
N=1with the guard always-on is the same regime that produced the repo's
delta=0 / -0.37 corpus-A/B nulls.
The attempt rate over time sidesteps all three: it is measured on the input side of the redirect, so the redirect cannot move it; the trend is across the agent's own history, needing no off-arm counterfactual.
Pre-registered decision rule
Fixed before any data is collected:
- Minimum N: at least 30 sessions with the guard installed before any
trend claim is made. Below N, report raw counts only — no verdict.
- Noise floor: fire counts are low-rate and bursty (one footgun cluster can
spike a single session). A declining trend counts only if it survives a per-session-median (or 5-session moving-average) smoothing — a single quiet session is not a trend.
- Earns its keep (KEEP): at N ≥ 30, the smoothed fire-attempt rate shows a
monotone-ish downward trend (later windows strictly below earlier windows) AND the guard demonstrably caused at least one redirect (≥1 fire) without ever firing on the happy path (zero false-positive telemetry lines). This is positive behavior-change evidence per ADR-0002 l.58.
- NULL is ACCEPTABLE (KEEP-on-no-harm): if at N ≥ 30 the rate is flat or the
trend is inconclusive, that is an expected, acceptable outcome — not a project failure. The repo's measured A/B base rate for context interventions is null/negative; a flat attempt-rate paired with zero context tax (silent on every happy path, asserted by the keystone bats) and zero false positives satisfies the ADR-0002 l.58 "lease on life" as no harm + a measurable signal channel that exists and runs. A guard that is provably silent and provably fires only on the real mistake-token has earned its keep even with a flat trend, because the failure mode it replaces (unproven, noisy, always-injecting hooks) is strictly worse.
- CUT: the guard is cut if, at N ≥ 30, telemetry shows it fired on the **happy
path (any false-positive line — a path that was not an installed-skill edit), OR the emission imposed a measurable context/latency tax, OR the fire-attempt rate rises** with no operator explanation. Any of these means it costs more than it proves.
Falsifiability summary
| Outcome at N ≥ 30 | Verdict | Rationale |
|---|---|---|
| Smoothed attempt-rate declines, ≥1 true fire, 0 false fires | KEEP | positive behavior-change evidence (ADR-0002 l.58) |
| Attempt-rate flat/inconclusive, 0 false fires, 0 tax | KEEP (null = acceptable) | no harm + live signal channel; beats unproven always-on hooks |
| Any false-positive fire, OR measurable tax, OR rising rate | CUT | costs more than it proves |
Reproducing the read (when N is reached)
# Fires per session, oldest→newest:
jq -r 'select(.token_class=="installed-skill-edit") | .session' \
"${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agentops/guardrail-telemetry.jsonl}" \
| sort | uniq -c
# Distinct targets touched (hashes), to spot repeated footguns:
jq -r 'select(.token_class=="installed-skill-edit") | .path_sha256' \
"${AGENTOPS_GUARDRAIL_TELEMETRY:-$HOME/.agentops/guardrail-telemetry.jsonl}" \
| sort | uniq -c | sort -rnNo raw path is ever available in the ledger — only hashes — so the read is privacy-preserving by construction.
Hook Events Reference
Complete documentation for all Claude Code hook events.
PreToolUse
When: After Claude creates tool parameters, before tool execution Can Block: Yes
Input Schema
{
"session_id": "string",
"transcript_path": "/path/to/session.jsonl",
"cwd": "/current/directory",
"permission_mode": "default|plan|acceptEdits|dontAsk|bypassPermissions",
"hook_event_name": "PreToolUse",
"tool_name": "Bash|Write|Edit|Read|Glob|Grep|Task|WebFetch|WebSearch|mcp__*",
"tool_input": { /* tool-specific */ },
"tool_use_id": "toolu_01ABC..."
}Tool-Specific Inputs
Bash:
{
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
}Write:
{
"file_path": "/absolute/path/to/file.txt",
"content": "file content"
}Edit:
{
"file_path": "/absolute/path/to/file.txt",
"old_string": "original text",
"new_string": "replacement",
"replace_all": false
}Read:
{
"file_path": "/absolute/path/to/file.txt",
"offset": 0,
"limit": 100
}Output: Decision Control
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow|deny|ask",
"permissionDecisionReason": "Explanation",
"updatedInput": { "field": "modified value" },
"additionalContext": "Context added for Claude"
}
}| Decision | Effect |
|---|---|
allow | Bypass permission system, execute immediately |
deny | Block execution, reason shown to Claude |
ask | Show permission dialog to user |
---
PostToolUse
When: Immediately after tool completes successfully Can Block: No (tool already ran)
Input Schema
{
"session_id": "string",
"transcript_path": "/path/to/session.jsonl",
"cwd": "/current/directory",
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Write",
"tool_input": { /* original input */ },
"tool_response": { /* tool result */ },
"tool_use_id": "toolu_01ABC..."
}Output: Feedback to Claude
{
"decision": "block",
"reason": "Linting errors found. Fix before continuing.",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Error on line 42: missing semicolon"
}
}---
PermissionRequest
When: Permission dialog is about to be shown Can Block: Yes (auto-allow or auto-deny)
Output: Auto-Resolve Permission
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow|deny",
"updatedInput": { "command": "safe-command" },
"message": "Reason for denial",
"interrupt": false
}
}
}---
UserPromptSubmit
When: User submits prompt, before Claude processes Can Block: Yes
Input Schema
{
"session_id": "string",
"hook_event_name": "UserPromptSubmit",
"prompt": "User's input text"
}Output: Add Context or Block
Add context (simple): Print to stdout with exit 0
echo "Current time: $(date)"
exit 0Add context (JSON):
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Project is in maintenance mode until 5pm"
}
}Block prompt:
{
"decision": "block",
"reason": "Cannot process: contains sensitive data"
}---
Stop
When: Claude finishes responding (not on user interrupt) Can Block: Yes (force continue)
Input Schema
{
"session_id": "string",
"hook_event_name": "Stop",
"stop_hook_active": true,
"transcript_path": "/path/to/session.jsonl"
}Important: Check stop_hook_active to prevent infinite loops.
Output: Force Continue
{
"decision": "block",
"reason": "Tests are failing. Fix the errors in src/auth.ts"
}Prompt-Based Stop Hook
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS. Check if all tasks complete.",
"timeout": 30
}
]
}
]
}
}LLM responds: {"ok": true} or {"ok": false, "reason": "Tasks incomplete"}
---
SubagentStop
When: Subagent (Task tool) finishes Can Block: Yes
Input Schema
{
"session_id": "string",
"hook_event_name": "SubagentStop",
"stop_hook_active": false,
"agent_id": "def456",
"agent_transcript_path": "/path/to/subagents/agent-def456.jsonl"
}---
SubagentStart
When: Subagent is spawned Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "SubagentStart",
"agent_id": "agent-abc123",
"agent_type": "Explore|Plan|Bash|custom-name"
}---
SessionStart
When: Session begins or resumes Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "SessionStart",
"source": "startup|resume|clear|compact",
"model": "claude-sonnet-4-6",
"agent_type": "agent-name"
}Matchers
startup- New sessionresume- From --resume, --continue, /resumeclear- After /clearcompact- After compaction
Persisting Environment Variables
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=production' >> "$CLAUDE_ENV_FILE"
echo 'export API_KEY=xxx' >> "$CLAUDE_ENV_FILE"
fi
exit 0---
SessionEnd
When: Session terminates Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "SessionEnd",
"reason": "clear|logout|prompt_input_exit|other"
}---
Notification
When: Claude Code sends notifications Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "Notification",
"message": "Claude needs your permission to use Bash",
"notification_type": "permission_prompt|idle_prompt|auth_success|elicitation_dialog"
}Example: Custom Desktop Notifications
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{ "type": "command", "command": "notify-send 'Claude Code' 'Permission needed'" }
]
},
{
"matcher": "idle_prompt",
"hooks": [
{ "type": "command", "command": "notify-send 'Claude Code' 'Waiting for input'" }
]
}
]
}
}---
PreCompact
When: Before context compaction Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "PreCompact",
"trigger": "manual|auto",
"custom_instructions": ""
}Matchers
manual- From /compact commandauto- Automatic due to full context
---
Setup
When: Invoked with --init, --init-only, or --maintenance Can Block: No
Input Schema
{
"session_id": "string",
"hook_event_name": "Setup",
"trigger": "init|maintenance"
}Matchers
init- From --init or --init-onlymaintenance- From --maintenance
Has access to CLAUDE_ENV_FILE for persisting environment.
---
MCP Tool Naming
MCP tools follow pattern: mcp__<server>__<tool>
{
"matcher": "mcp__memory__.*",
"hooks": [{ "type": "command", "command": "log-memory-ops.sh" }]
}Examples:
mcp__memory__create_entitiesmcp__filesystem__read_filemcp__github__search_repositories
Installed-Skill-Edit Guard (opt-in)
A PreToolUse Edit|Write guard that routes an edit of an installed skill copy (*/.claude/skills/**, */.codex/skills/**, */.gemini/skills/**) back to the repo source of truth (skills/<name>/). AgentOps 3.0 is hookless by design — this guard ships inert; you activate it with the opt-in installer.
Bead: age-workflow-guardrail-hooks-j39.1 (keystone of age-workflow-guardrail-hooks-j39).
Why it exists — a TRUE mistake-token
An Edit/Write whose target path is under */.claude/skills/** has no legitimate form. Those files are installed / symlinked copies:
- they are overwritten on
scripts/install.sh, so an edit there is silently
lost work, or
- they symlink through to the factory checkout, so an edit there writes into
whatever branch that checkout happens to be on — never the intended source.
CLAUDE.md's standing rule is "NEVER edit ~/.claude/skills/ — edit skills/ in this repo." That rule is advisory context, which is delta≈0. This guard makes it mechanical: it keys on the action signature (the file_path), not the agent's self-narrative, so it fires even when the agent believes it is doing the right thing.
Unlike an activity-keyed guard (which false-fires on legitimate identical forms and gets disabled — the #511 fate), this token is syntactically detectable with zero false-positive surface: only an installed-skills file_path matches, and a repo doc that merely mentions claude/skills in its body lands in tool_input.content, never file_path.
Reversible → ROUTE, not hard-block
Editing the wrong copy is recoverable (re-do the edit against skills/), so the guard routes rather than hard-blocks: exit 2 + a one-line stderr redirect naming the correct skills/<name>/ target. It does not silently swallow the edit or deny irreversibly.
Context-budget doctrine
Hooks are the most powerful enforcement (mechanical, can't be reasoned past) but they pollute context — use sparingly:
- SILENT on the happy path: any non-installed-skills
file_path→ exit 0,
zero stdout, zero stderr.
- Fire the one redirect only on a real violation, at most **once per
session** (sentinel-gated in $TMPDIR), so it never repeats.
- NEVER emit stray stdout on an exit-0 PreToolUse path — stdout there is
parsed as JSON and a stray line breaks the tool call. Block via exit 2 + stderr only.
The guard
Ships as skills/cc-hooks/hooks/installed-skill-edit-guard.sh. It reads the PreToolUse JSON on stdin, matches tool_input.file_path only, and derives the repo-relative skills/<name>/ target for the redirect message.
Opt-in install
# user scope (~/.claude/settings.json) — the default
scripts/install-installed-skill-edit-guard.sh
# project scope (.claude/settings.json)
scripts/install-installed-skill-edit-guard.sh --project
# explicit target
SETTINGS=/path/to/settings.json scripts/install-installed-skill-edit-guard.shThe installer copies the guard to ~/.claude/hooks/installed-skill-edit-guard.sh and adds (idempotently) a PreToolUse Edit|Write matcher:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/installed-skill-edit-guard.sh" }
]
}
]
}
}Requires jq on PATH.
Test it
tests/scripts/installed-skill-edit-guard.bats round-trips the real PreToolUse JSON shape on stdin and proves the contract:
- FIRE (exit 2):
~/.claude/skills/<x>/SKILL.md, an absolute
/Users/*/.claude/skills/**, .codex/skills/**, .gemini/skills/**.
- SILENT (exit 0, zero output): repo
skills/**(absolute or relative), an
unrelated source file, a doc whose path mentions claude but not the installed-skills segment, and a missing file_path.
- once-per-session: first violation fires, the second self-relaxes.
bats tests/scripts/installed-skill-edit-guard.batsKnown limitations
It matches the file_path only, so it cannot guard an edit reached through a tool that does not populate file_path (e.g. a Bash sed -i into the installed copy) — that is a Bash path, not an Edit/Write, and out of scope here. The cost of a missed case is one un-routed edit; there is no false fire and no broken tool call. Erring toward silence keeps it cheap on context and safe to run.
JSON Output Reference
Complete schemas for hook JSON responses.
Common Fields (All Hooks)
{
"continue": true,
"stopReason": "Why Claude should stop",
"suppressOutput": false,
"systemMessage": "Warning shown to user"
}| Field | Type | Description |
|---|---|---|
continue | boolean | If false, Claude stops after hooks run |
stopReason | string | Message shown when continue=false |
suppressOutput | boolean | Hide from verbose mode (ctrl+o) |
systemMessage | string | Warning displayed to user |
---
PreToolUse
Allow (Auto-Approve)
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Safe operation auto-approved"
}
}Deny (Block)
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Blocked: dangerous command"
}
}Ask (Show Dialog)
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "Requires explicit approval"
}
}Modify Input
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Modified for safety",
"updatedInput": {
"command": "npm run lint -- --fix",
"timeout": 60000
}
}
}Add Context
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "Environment: production. Proceed with caution."
}
}Full Example
{
"continue": true,
"suppressOutput": true,
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Build command routed to remote worker",
"updatedInput": {
"command": "rch-exec cargo build --release"
},
"additionalContext": "Build will execute on worker-1 (32 cores)"
}
}---
PermissionRequest
Allow Permission
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow"
}
}
}Allow with Modified Input
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": {
"command": "npm run build:safe"
}
}
}
}Deny Permission
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Denied: production deployment requires approval",
"interrupt": false
}
}
}Deny and Stop Claude
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Critical: manual intervention required",
"interrupt": true
}
}
}---
PostToolUse
Provide Feedback (Block)
{
"decision": "block",
"reason": "Linting errors found. Fix before continuing.",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Errors:\n- line 42: missing semicolon\n- line 55: unused variable"
}
}Add Context Only
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "File formatted successfully with prettier"
}
}---
UserPromptSubmit
Add Context (Simpler)
Just print to stdout:
echo "Current time: $(date)"
echo "Project: myapp v1.2.3"Add Context (JSON)
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "User is working on feature-auth branch. 3 open PRs pending review."
}
}Block Prompt
{
"decision": "block",
"reason": "Prompt contains potentially sensitive data. Please rephrase."
}---
Stop / SubagentStop
Allow Stop (Default)
No output needed, or:
{}Force Continue
{
"decision": "block",
"reason": "Tests are failing. Run `npm test` and fix errors in src/auth.ts before stopping."
}---
SessionStart
Add Context
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "Project: myapp\nBranch: feature-auth\nOpen issues: 5"
}
}---
Setup
Add Context
{
"hookSpecificOutput": {
"hookEventName": "Setup",
"additionalContext": "Dependencies installed. Database migrations applied."
}
}---
Prompt-Based Hook Response
For type: "prompt" hooks:
Allow
{
"ok": true
}Block/Deny
{
"ok": false,
"reason": "Tasks incomplete. The test suite is still failing."
}---
Exit Code Behavior Summary
| Exit Code | JSON Parsed? | Effect |
|---|---|---|
| 0 | Yes | Success, JSON controls behavior |
| 2 | No | Block, stderr fed to Claude |
| Other | No | Non-blocking error, stderr to verbose |
Important: Exit code 2 ignores any JSON output. Use stderr for the message.
---
Deprecated Fields
These still work but use new format:
| Old | New |
|---|---|
decision: "approve" | permissionDecision: "allow" |
decision: "block" | permissionDecision: "deny" |
reason | permissionDecisionReason |
---
Field Availability by Event
| Field | PreToolUse | PostToolUse | UserPromptSubmit | Stop |
|---|---|---|---|---|
continue | ✓ | ✓ | ✓ | ✓ |
decision | ✓ | ✓ | ✓ | ✓ |
permissionDecision | ✓ | - | - | - |
updatedInput | ✓ | - | - | - |
additionalContext | ✓ | ✓ | ✓ | - |
reason | ✓ | ✓ | ✓ | ✓ |
Hook Patterns and Recipes
Common patterns for Claude Code hooks.
Auto-Format on File Write
TypeScript/JavaScript with Prettier
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.ts || \"$f\" == *.tsx || \"$f\" == *.js ]] && npx prettier --write \"$f\"; } || true"
}
]
}
]
}
}Go with gofmt
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.go ]] && gofmt -w \"$f\"; } || true"
}
]
}
]
}
}Rust with rustfmt
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | { read f; [[ \"$f\" == *.rs ]] && rustfmt \"$f\"; } || true"
}
]
}
]
}
}Multi-Language Formatter
#!/bin/bash
# ~/.claude/hooks/auto-format.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path')
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
npx prettier --write "$FILE" 2>/dev/null
;;
*.go)
gofmt -w "$FILE" 2>/dev/null
;;
*.rs)
rustfmt "$FILE" 2>/dev/null
;;
*.py)
black "$FILE" 2>/dev/null || ruff format "$FILE" 2>/dev/null
;;
esac
exit 0---
File Protection
Block Sensitive Files
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep -qE '(\\.env|\\.git/|credentials|secrets|password)' && { echo 'Blocked: sensitive file' >&2; exit 2; } || exit 0"
}
]
}
]
}
}Block Production Paths
#!/bin/bash
INPUT=$(cat)
PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path')
BLOCKED_PATTERNS=(
"/prod/"
"/production/"
"deploy/"
".env.production"
)
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if [[ "$PATH" == *"$pattern"* ]]; then
echo "Blocked: production file $PATH" >&2
exit 2
fi
done
exit 0---
Command Validation
Block Dangerous Git Commands
#!/usr/bin/env python3
import json
import sys
import re
DANGEROUS_PATTERNS = [
r'git\s+reset\s+--hard',
r'git\s+clean\s+-[fd]',
r'git\s+push\s+.*--force',
r'git\s+checkout\s+--\s+\.',
r'git\s+branch\s+-D',
]
input_data = json.load(sys.stdin)
command = input_data.get('tool_input', {}).get('command', '')
for pattern in DANGEROUS_PATTERNS:
if re.search(pattern, command):
print(f"Blocked: dangerous git command", file=sys.stderr)
sys.exit(2)
sys.exit(0)Suggest Better Commands
#!/usr/bin/env python3
import json
import sys
import re
SUGGESTIONS = [
(r'\bgrep\b(?!.*\|)', "Use 'rg' (ripgrep) instead of grep"),
(r'\bfind\s+\S+\s+-name\b', "Use 'fd' or 'rg --files' instead of find"),
(r'\bcat\s+\S+\s*\|\s*grep', "Use 'rg pattern file' directly"),
]
input_data = json.load(sys.stdin)
command = input_data.get('tool_input', {}).get('command', '')
for pattern, suggestion in SUGGESTIONS:
if re.search(pattern, command):
print(f"Suggestion: {suggestion}", file=sys.stderr)
# Non-blocking - just advice
break
sys.exit(0)---
Logging and Auditing
Log All Commands
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '\"\\(.tool_input.command) - \\(.tool_input.description // \"No description\")\"' >> ~/.claude/bash-command-log.txt"
}
]
}
]
}
}Structured JSON Logging
#!/bin/bash
INPUT=$(cat)
TIMESTAMP=$(date -Iseconds)
LOG_ENTRY=$(echo "$INPUT" | jq -c --arg ts "$TIMESTAMP" '{timestamp: $ts, tool: .tool_name, input: .tool_input}')
echo "$LOG_ENTRY" >> ~/.claude/hooks.jsonl
exit 0Log to Syslog
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // .tool_input.file_path // "unknown"')
logger -t claude-code "Tool: $TOOL, Target: $CMD"
exit 0---
Custom Notifications
Desktop Notification (Linux)
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "notify-send -u critical 'Claude Code' 'Permission required'"
}
]
},
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Waiting for your input'"
}
]
}
]
}
}macOS Notification
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Awaiting input\" with title \"Claude Code\"'"
}
]
}
]
}
}Slack/Discord Webhook
#!/bin/bash
INPUT=$(cat)
MSG=$(echo "$INPUT" | jq -r '.message')
curl -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"Claude Code: $MSG\"}" \
2>/dev/null
exit 0---
Context Injection
Add Project Context at Session Start
#!/bin/bash
# SessionStart hook
if [ -f "$CLAUDE_PROJECT_DIR/.claude/context.md" ]; then
cat "$CLAUDE_PROJECT_DIR/.claude/context.md"
fi
# Add git status
echo "Current branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')"
echo "Modified files: $(git status --porcelain 2>/dev/null | wc -l || echo 0)"
exit 0Add Context from External Tool
#!/usr/bin/env python3
import json
import subprocess
import sys
# Get current issues
result = subprocess.run(['gh', 'issue', 'list', '--limit', '5', '--json', 'title,number'],
capture_output=True, text=True)
if result.returncode == 0:
issues = json.loads(result.stdout)
if issues:
output = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": f"Open issues: {json.dumps(issues)}"
}
}
print(json.dumps(output))
sys.exit(0)---
Stop Hook: Ensure Quality
Run Tests Before Stopping
#!/usr/bin/env python3
import json
import sys
import subprocess
input_data = json.load(sys.stdin)
# Prevent infinite loops
if input_data.get('stop_hook_active'):
sys.exit(0)
# Check if tests pass
result = subprocess.run(['npm', 'test'], capture_output=True, timeout=60)
if result.returncode != 0:
output = {
"decision": "block",
"reason": f"Tests failing. Fix before stopping. Error: {result.stderr.decode()[:500]}"
}
print(json.dumps(output))
sys.exit(0)Check for Uncommitted Changes
#!/bin/bash
INPUT=$(cat)
# Skip if already in stop loop
if echo "$INPUT" | jq -e '.stop_hook_active' > /dev/null 2>&1; then
exit 0
fi
# Check for uncommitted changes
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo '{"decision":"block","reason":"Uncommitted changes detected. Commit or stash before finishing."}'
fi
exit 0---
Prompt-Based Hook (LLM Evaluation)
Intelligent Stop Decision
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop. Context: $ARGUMENTS\n\nCheck:\n1. Are all requested tasks complete?\n2. Are there any errors that need fixing?\n3. Is follow-up work needed?\n\nRespond: {\"ok\": true} to stop, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
"timeout": 30
}
]
}
]
}
}---
Environment Setup
Load nvm/Node Version
#!/bin/bash
# SessionStart hook with CLAUDE_ENV_FILE
ENV_BEFORE=$(export -p | sort)
# Load nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
# Use project's node version
if [ -f ".nvmrc" ]; then
nvm use 2>/dev/null
fi
# Persist environment changes
if [ -n "$CLAUDE_ENV_FILE" ]; then
ENV_AFTER=$(export -p | sort)
comm -13 <(echo "$ENV_BEFORE") <(echo "$ENV_AFTER") >> "$CLAUDE_ENV_FILE"
fi
exit 0Activate Python Virtualenv
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
if [ -d ".venv" ]; then
echo 'export VIRTUAL_ENV=".venv"' >> "$CLAUDE_ENV_FILE"
echo 'export PATH=".venv/bin:$PATH"' >> "$CLAUDE_ENV_FILE"
fi
fi
exit 0---
Skill/Agent Scoped Hooks
In SKILL.md Frontmatter
---
name: secure-deployment
description: Deploy with security checks
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "$CLAUDE_PROJECT_DIR/.claude/hooks/deploy-check.sh"
once: true # Only runs once per session
---In Subagent Definition
---
name: code-reviewer
hooks:
PostToolUse:
- matcher: "Edit|Write"
hooks:
- type: command
command: "./scripts/lint-check.sh"
---Skill-First Coordination Guard (opt-in recipe)
A copy-paste PreToolUse hook pair that nudges an agent to load the coordination skill before hand-rolling the `am` / `atm` / `ntm` / `tmux send-keys` CLI surfaces. AgentOps 3.0 is hookless by design — nothing here auto-installs. This is documentation plus a recipe you opt into per host.
Why it exists
The dominant multi-agent failure mode is an agent reverse-engineering the coordination CLI (Agent Mail, ATM/NTM, raw tmux send-keys) from first principles instead of loading the skill that already carries the command surface and the doctrine. The skill knows the reservation protocol, the inbox model, the liveness truth stack; the hand-rolled invocation does not. This guard fires one loud nudge the first time it sees a bare coordination command in a session, then self-relaxes the moment the relevant skill loads.
Context-budget doctrine for hooks
Hooks are the most powerful enforcement available — mechanical, can't be reasoned past — but they pollute context, so use them sparingly:
- A hook must be SILENT on the happy path: exit 0, no stdout, no stderr.
- Fire only on a real violation, ideally once per session,
sentinel-gated so it never repeats.
- Prefer PreToolUse violation-guards over
UserPromptSubmit/
SessionStart per-turn injectors — the latter pay context on every turn whether or not anything is wrong.
- NEVER emit stray stdout on an exit-0 PreToolUse path — stdout there is
parsed as JSON and a stray line breaks the tool call. Block via exit 2 + stderr instead.
This recipe is built to that doctrine: silent on every non-coordination command, one stderr message gated by a per-session sentinel file, self-relaxing after the skill loads.
The matching defect this recipe fixes
A naive line-based match — grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*(am|atm|ntm)([[:space:]]|$)' — over-matches: grep is line-oriented, so ^ matches every heredoc-body line, and a quoted |ntm inside an argument reads as a top-level | delimiter. A br create "t" --body "...mentions am/atm/ntm and agent-mail|ntm|using-atm..." would falsely fire even though no coordination command is being run.
The fix below matches am/atm/ntm/tmux send-keys only as an actual command head — never inside quoted strings, heredoc bodies, or prose. It strips quoted spans (multiline-aware) and heredoc bodies, splits the remainder on top-level separators (; & | newline), and tests only the head token of each segment (skipping leading VAR=val assignments).
Script 1 — the guard (skill-first-coord-guard.sh)
PreToolUse / Bash. Fires once per session on a real hand-roll; silent otherwise.
#!/usr/bin/env bash
# skill-first-coord-guard (PreToolUse / Bash)
# Nudge to load the agent-mail / ntm (ATM) skill BEFORE hand-rolling the
# am / atm / ntm / tmux-send-keys CLI surfaces.
#
# Context-budget discipline (hooks are powerful but pollute context — use sparingly):
# - SILENT on the happy path (non-coordination commands → exit 0, no output).
# - Fires its one loud message ONLY on an actual hand-roll, and at most ONCE
# per session. Self-relaxes after the coordination skill loads
# (skill-first-coord-mark.sh) or after the single nag.
set -uo pipefail
input="$(cat)"
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""')"
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
# Match the coordination CLI (am/atm/ntm) or `tmux send-keys` ONLY as an actual
# command HEAD — never inside quoted strings, heredoc bodies, or prose. A naive
# line-based grep over-matches: `^` matches every heredoc-body line, and a
# quoted `|ntm` reads as a top-level delimiter, so a `br create` whose BODY
# merely mentions am/atm/ntm would falsely fire. So we:
# 1. strip single/double-quoted spans (multiline-aware) and heredoc bodies,
# 2. split what remains on top-level separators ( ; & | newline ),
# 3. test only the HEAD token of each segment (skipping VAR=val assignments).
is_coord=0
stripped="$(printf '%s' "$cmd" | perl -0777 -pe "
s/'[^']*'//g; # single-quoted spans
s/\"[^\"]*\"//g; # double-quoted spans (multiline)
s/<<-?\s*([A-Za-z_][A-Za-z0-9_]*).*?^\s*\1\b//gms; # heredoc bodies
")"
printf '%s' "$stripped" | awk '
BEGIN { RS="[;&\n]|\\|\\|?"; FS="[ \t]+" }
{
i=1
while (i<=NF && ($i=="" || $i ~ /^[A-Za-z_][A-Za-z0-9_]*=/)) i++ # skip VAR=val
head=$i
if (head=="am" || head=="atm" || head=="ntm") { found=1 }
if (head=="tmux") { nxt=$(i+1); if (nxt=="send-keys") found=1 } # tmux send-keys
}
END { exit (found?0:1) }
' && is_coord=1
[ "$is_coord" -eq 1 ] || exit 0
dir="${TMPDIR:-/tmp}/claude-coordguard"
sentinel="$dir/${sid//\//_}"
[ -f "$sentinel" ] && exit 0 # skill already loaded, or already nagged this session
mkdir -p "$dir" 2>/dev/null || true
: > "$sentinel" 2>/dev/null || true
cat >&2 <<'MSG'
⛔ SKILL-FIRST (coordination): load the skill before hand-rolling the AM/ATM CLI.
• am (Agent Mail) → Skill tool: agent-mail
• atm / ntm (ATM swarm) → Skill tool: ntm (or using-atm)
• tmux send-keys to a pane → Skill tool: ntm
The skill carries the command surface + doctrine — don't reverse-engineer the CLI.
Fires once per session and self-relaxes after the skill loads. Re-run your command.
MSG
exit 2Script 2 — the mark (skill-first-coord-mark.sh)
PreToolUse / Skill. Silently records that a coordination skill loaded so the guard self-relaxes. Zero context output — pure side effect.
#!/usr/bin/env bash
# skill-first-coord-mark (PreToolUse / Skill)
# Silently record that a coordination skill loaded this session so the
# skill-first-coord-guard self-relaxes. ZERO context output — pure side effect.
set -uo pipefail
input="$(cat)"
skill="$(printf '%s' "$input" | jq -r '.tool_input.skill // ""')"
case "$skill" in
agent-mail|ntm|using-atm)
sid="$(printf '%s' "$input" | jq -r '.session_id // "nosession"')"
dir="${TMPDIR:-/tmp}/claude-coordguard"
mkdir -p "$dir" 2>/dev/null || true
: > "$dir/${sid//\//_}" 2>/dev/null || true
;;
esac
exit 0Opt-in install
1. Save both scripts (e.g. to ~/.claude/hooks/) and chmod +x them. 2. Add the hook pair to ~/.claude/settings.json (user) or .claude/settings.json (project). Note the two separate matchers — Bash runs the guard, Skill runs the mark:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/skill-first-coord-guard.sh" }
]
},
{
"matcher": "Skill",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/skill-first-coord-mark.sh" }
]
}
]
}
}Requires jq, perl, and awk on PATH (all standard on macOS and Linux).
Test it (and prove it)
A small bats test exercising the fire / silent / once-per-session contract. Save as tests/skill-first-coord-guard.bats and run with bats <file>:
#!/usr/bin/env bats
# Contract for skill-first-coord-guard.sh:
# FIRE (exit 2): am robot status · atm up · ntm --robot-attention
# git commit -m x && am mail send · tmux send-keys -t x hi
# SILENT (exit 0): ls -la · npm test · team build · echo "I am here"
# br create "t" --body "...am/atm/ntm... agent-mail|ntm|using-atm..."
GUARD="${GUARD:-$HOME/.claude/hooks/skill-first-coord-guard.sh}"
setup() { export TMPDIR="$(mktemp -d)"; }
run_guard() { # $1=command $2=session_id
jq -nc --arg c "$1" --arg s "$2" '{tool_input:{command:$c},session_id:$s}' | bash "$GUARD"
}
@test "FIRE: am robot status" { run run_guard 'am robot status' "s1"; [ "$status" -eq 2 ]; }
@test "FIRE: atm up" { run run_guard 'atm up' "s2"; [ "$status" -eq 2 ]; }
@test "FIRE: ntm --robot-attention" { run run_guard 'ntm --robot-attention' "s3"; [ "$status" -eq 2 ]; }
@test "FIRE: chained && am mail send" { run run_guard 'git commit -m x && am mail send' "s4"; [ "$status" -eq 2 ]; }
@test "FIRE: tmux send-keys" { run run_guard 'tmux send-keys -t x hi' "s5"; [ "$status" -eq 2 ]; }
@test "SILENT: ls -la" { run run_guard 'ls -la' "s6"; [ "$status" -eq 0 ]; [ -z "$output" ]; }
@test "SILENT: npm test" { run run_guard 'npm test' "s7"; [ "$status" -eq 0 ]; [ -z "$output" ]; }
@test "SILENT: team build" { run run_guard 'team build' "s8"; [ "$status" -eq 0 ]; [ -z "$output" ]; }
@test "SILENT: echo quoted am" { run run_guard 'echo "I am here"' "s9"; [ "$status" -eq 0 ]; [ -z "$output" ]; }
@test "SILENT: br create body mentions am/atm/ntm (false-positive guard)" {
run run_guard 'br create "t" --body "...am/atm/ntm... agent-mail|ntm|using-atm..."' "s10"
[ "$status" -eq 0 ]; [ -z "$output" ]
}
@test "once-per-session: first fires, second self-relaxes" {
run run_guard 'am robot status' "same"; [ "$status" -eq 2 ]
run run_guard 'atm up' "same"; [ "$status" -eq 0 ]
}Proven output of an equivalent pure-shell harness against all contract cases (every FIRE → exit 2, every SILENT → exit 0 with zero stderr bytes, including the br create false-positive case and a multiline heredoc body):
=== FIRE (expect exit 2) ===
exit=2 am robot status
exit=2 atm up
exit=2 ntm --robot-attention
exit=2 git commit -m x && am mail send
exit=2 tmux send-keys -t x hi
=== SILENT (expect exit 0, no stderr) ===
exit=0 ls -la [stderr-bytes=0]
exit=0 npm test [stderr-bytes=0]
exit=0 team build [stderr-bytes=0]
exit=0 echo "I am here" [stderr-bytes=0]
exit=0 br create "t" --body "...am/atm/ntm... agent-mail|ntm|..." [stderr-bytes=0]
=== once-per-session sentinel ===
1st am : exit=2
2nd atm: exit=0Known limitations
The guard tests only the actual command head, so it intentionally does NOT fire when a coordination CLI is reached indirectly — via command-substitution ($(am …)), backticks, or a command-prefix wrapper (time am …, env X=1 am …). This is by design: the guard is an opt-in nudge, not a security boundary. The cost of a missed case is exactly one un-nudged hand-roll — no false fire, no broken command. Erring toward silence keeps it cheap on context and safe to run. The recipe also requires jq, awk, and perl on PATH.
{
"name": "cc-hooks",
"skill_api_version": 1,
"form": "A",
"quality_score": 0.92,
"sections": [
{ "id": "title", "title": "Claude Code Hooks", "type": "intro", "priority": "required" },
{ "id": "quickstart", "title": "Quick Start", "type": "procedure", "priority": "required" },
{ "id": "events", "title": "Hook Events", "type": "table", "priority": "required" },
{ "id": "matchers", "title": "Matchers", "type": "overview", "priority": "required" },
{ "id": "exitcodes", "title": "Exit Codes", "type": "table", "priority": "required" },
{ "id": "blocking", "title": "Blocking a Tool", "type": "procedure", "priority": "required" },
{ "id": "modifying", "title": "Modifying Input", "type": "procedure", "priority": "standard" },
{ "id": "dcgrch", "title": "Real-World: DCG + RCH", "type": "examples", "priority": "standard" },
{ "id": "writing", "title": "Writing Your Own Hook", "type": "procedure", "priority": "required" },
{ "id": "envvars", "title": "Environment Variables", "type": "table", "priority": "standard" },
{ "id": "stophook", "title": "Stop Hook (Force Continue)", "type": "procedure", "priority": "standard" },
{ "id": "antipattern", "title": "Anti-Patterns", "type": "constraints", "priority": "required" },
{ "id": "debugging", "title": "Debugging", "type": "procedure", "priority": "standard" },
{ "id": "references", "title": "References", "type": "routing", "priority": "required" }
],
"references": [
{ "file": "references/HOOK-EVENTS.md", "topic": "all hook events with full input/output schemas" },
{ "file": "references/DCG-RCH.md", "topic": "production examples (dcg, rch) wired as PreToolUse hooks" },
{ "file": "references/SKILL-FIRST-COORDINATION-GUARD.md", "topic": "opt-in skill-first coordination guard recipe + hook context-budget doctrine" },
{ "file": "references/PATTERNS.md", "topic": "auto-format, logging, notification hook patterns" },
{ "file": "references/JSON-OUTPUT.md", "topic": "hook response JSON schemas" }
],
"metadata": {
"tier": "execution",
"stability": "stable",
"dependencies": ["dcg", "rch"],
"hexagonal_role": "supporting",
"context_window": "inherit",
"practices": ["pragmatic-programmer"],
"triggers": [
"cc-hooks",
"Claude Code hooks",
"PreToolUse",
"PostToolUse",
"Stop hook",
"Notification hook",
"block a command",
"auto-format on edit",
"custom permissions",
"write a hook"
],
"token_estimate": {
"minimal": 60,
"overview": 220,
"standard": 650,
"full": 1200
}
},
"output_contract": "A hooks block in ~/.claude/settings.json or .claude/settings.json (matcher + command entries), or a hook script that reads tool JSON on stdin and signals allow/deny/ask via exit codes (0/2) or hookSpecificOutput JSON. Stop hooks must guard stop_hook_active against infinite loops.",
"evidence": {
"sources": [
"skills/cc-hooks/SKILL.md",
"skills/cc-hooks/references/HOOK-EVENTS.md",
"skills/cc-hooks/references/DCG-RCH.md",
"skills/cc-hooks/references/JSON-OUTPUT.md"
]
}
}
Related skills
FAQ
Which hook events does cc-hooks cover?
PreToolUse, PostToolUse, Stop, and Notification.
Where are hooks configured?
In ~/.claude/settings.json (user) or .claude/settings.json (project).