---
name: claude-code-hooks-config
source: https://app.decimal.ai/s/claude-code-hooks-config@1/SKILL.md
source_sha256: 4dd30311ad59
---

# Claude Code hooks configuration

## Contract

Claude Code hooks are declared in `.claude/settings.json` under one top-level `hooks` object that maps a
lifecycle EVENT to matcher groups, each running shell commands. Apply this whenever wiring automation
(format, lint, test, guard, notify) to Claude's tool use or session lifecycle. The shape is fixed and
non-obvious; guessing the field names, casing, or exit-code meaning produces config that silently never fires.

## Rules

1. **Root key.** Everything sits under a single top-level object key spelled exactly `hooks` (lowercase) in
   `.claude/settings.json`.

2. **Event keys are PascalCase, from a fixed set.** Under `hooks`, each key is an event name in exact
   PascalCase — no lowercasing, no `snake_case`, no invented `on…`/`before…`/`after…` names. The common events:
   - `PreToolUse` — before a tool runs (can block it)
   - `PostToolUse` — after a tool completes
   - `UserPromptSubmit` — when you submit a message
   - `SessionStart` / `SessionEnd` — session begins / ends
   - `Stop` — the main agent finishes responding
   - `SubagentStop` — a spawned sub-agent finishes
   - `Notification` — Claude Code emits a notification (e.g. awaiting permission)
   - `PreCompact` — before history compaction

3. **Each event value is an ARRAY of matcher groups** — always a list, even for a single group. Never map the
   event straight to one handler object, and never key it by tool name.

4. **A matcher group is `{ "matcher": …, "hooks": [ … ] }`.**
   - `matcher` is a string. For `PreToolUse` / `PostToolUse` it matches the **tool name** — `Bash`, `Edit`,
     `Write`, `MultiEdit`, `Read`, `Grep`, `Task`, an `mcp__*` server tool, etc. It is matched as an unanchored
     regex, so `Edit|Write` covers several tools. It is **not** a file path or a glob like `*.py`.
   - Match every tool with `"*"`, `""`, or by omitting `matcher`.
   - Session/lifecycle events (`SessionStart`, `SessionEnd`, `PreCompact`, …) do not match tool names; a plain
     group without a tool matcher is fine.

5. **The nested handler array key is `hooks`** (the same word, one level deeper) — **not** `handlers`,
   `actions`, `commands`, or `steps`. It holds one or more handler objects, run in order.

6. **A command handler is `{ "type": "command", "command": "<shell>" }`.** The `type` is the literal string
   `"command"` (not `shell`, `exec`, `script`, or absent). The shell string goes under `command` (not `run`,
   `cmd`, `exec`, or `script`).

7. **Reference the project root with `$CLAUDE_PROJECT_DIR`.** To call a repo script from a command, prefix it
   with `$CLAUDE_PROJECT_DIR` (or `${CLAUDE_PROJECT_DIR}`), e.g. `"$CLAUDE_PROJECT_DIR/scripts/fmt.sh"`. Never
   hardcode an absolute machine path, and don't rely on `$PWD` — hooks may run from a nested directory.

8. **`timeout` is in SECONDS.** The optional per-handler `timeout` is a number of seconds (e.g. `60`), never
   milliseconds. `"timeout": 5000` means 83 minutes, not 5 seconds.

9. **Exit codes carry the decision.** A command hook signals through its exit status: `0` = success (stdout
   may hold JSON control); `2` = **blocking** error — stderr is fed back to Claude and, for `PreToolUse`, the
   tool call is blocked; any other non-zero code = non-blocking error (surfaced but execution continues). It is
   exit code **`2`**, not `1`, that blocks.

10. **Structured decisions go in JSON on stdout.** For fine-grained `PreToolUse` control, print JSON with a
    `hookSpecificOutput` object: `"hookEventName": "PreToolUse"` plus `"permissionDecision"` set to one of
    `"allow"`, `"deny"`, or `"ask"`, with a `"permissionDecisionReason"` string. Post-run events use a
    top-level `"decision": "block"` + `"reason"`. There is no bare top-level `block: true` / `allow: false`.

## Worked examples

**Format after every edit** (the base default vs. the real schema)

BEFORE (invented, will not fire):
```json
{ "hooks": { "afterEdit": { "run": "prettier --write ${file}" } } }
```
AFTER:
```json
{
  "hooks": {
    "PostToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [ { "type": "command", "command": "prettier --write \"$CLAUDE_PROJECT_DIR\"" } ] }
    ]
  }
}
```

**Guard shell commands** — block on rejection:

BEFORE:
```json
{ "hooks": { "preBash": { "command": "guard.sh", "blockOnExit": 1 } } }
```
AFTER (exit `2` blocks; project-root script):
```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/guard.sh" } ] }
    ]
  }
}
```

**Run a script at session start:**

BEFORE: `{ "hooks": { "onStart": "notes.sh" } }`
AFTER:
```json
{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/notes.sh" } ] } ] } }
```

**Timeout unit:**  BEFORE `"timeout": 60000`  →  AFTER `"timeout": 60`  (seconds).

## Edge cases & exceptions

- **All tools:** `"matcher": "*"`, `"matcher": ""`, or omit `matcher` entirely — do not enumerate every tool.
- **Several tools, one group:** use a regex alternation in one `matcher`, e.g. `"Edit|MultiEdit|Write"`.
- **Filtering by file type:** the `matcher` cannot select `.go`/`.py` files — it only matches the tool. Do the
  extension check inside the command (read the changed path from the hook's JSON stdin).
- **Multiple actions:** put several handler objects in the one nested `hooks` array; they run in order.
- **Session matchers:** `SessionStart` can match `startup|resume|clear|compact`; most hooks just omit it.

## Do / Don't

- DO name events in PascalCase (`PostToolUse`). DON'T invent `afterEdit`, `on_edit`, `posttooluse`.
- DO make each event value an array. DON'T map an event to a single object.
- DO nest handlers under `hooks`. DON'T use `handlers`, `actions`, or `commands`.
- DO set `"type": "command"`. DON'T omit `type` or use `shell`/`exec`.
- DO put the shell string under `command`. DON'T use `run`/`cmd`/`script`.
- DO set `matcher` to a tool name. DON'T set it to a file glob or a path.
- DO exit `2` to block. DON'T assume exit `1` blocks.
- DO use `$CLAUDE_PROJECT_DIR`. DON'T hardcode absolute paths or trust `$PWD`.
- DO express `timeout` in seconds. DON'T use milliseconds.

## Common mistakes

- Lowercase / camelCase / snake_case event names (`preToolUse`, `post_tool_use`) — the event never matches.
- Event value is an object instead of an array.
- `handlers` / `actions` instead of the nested `hooks` array.
- `matcher` set to a file glob (`*.js`) instead of the tool name.
- `type` omitted, or `run`/`cmd` used instead of `command`.
- Exit code `1` used to block (only `2` blocks; `1` is a non-blocking error).
- `timeout` given in milliseconds.
- A bare top-level `{ "block": true }` instead of `hookSpecificOutput.permissionDecision`.
- Hardcoded absolute script paths instead of `$CLAUDE_PROJECT_DIR`.

## Quick checklist

- [ ] Top-level `hooks` object in `.claude/settings.json`.
- [ ] Event key in exact PascalCase from the known set.
- [ ] Event value is an array of `{ matcher, hooks:[…] }` groups.
- [ ] `matcher` = tool name / regex / `*` (not a file glob).
- [ ] Nested handler array is keyed `hooks`.
- [ ] Each handler `"type": "command"` + `command`.
- [ ] Project scripts via `$CLAUDE_PROJECT_DIR`; `timeout` in seconds.
- [ ] Block with exit code `2`, or JSON `permissionDecision` allow|deny|ask.
