> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Claude Code Hooks — Automating the Boring Parts of My Workflow
- URL: https://blog.sudowl.com/claude-code-hooks-automate-workflow/
- Published: 2026-05-16T10:00:00.000Z
- Updated: 2026-10-11T08:47:37.000Z
- Description: How I use Claude Code hooks in settings.json to auto-run formatters, block rm -rf, and ping me when an agent stops. Real configs, exit-code contract.
- Author: Lukas
- Tags: AI

Claude Code writes code fast. Left alone it also forgets to format, occasionally drafts a command I never want run, and finishes work silently while I'm in another window. Hooks fix all three.

**TL;DR:** Put this in `~/.claude/settings.json` (or project `.claude/settings.json`):

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/format.sh" }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/guard-bash.sh" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/notify.sh" }
        ]
      }
    ]
  }
}

```

Details below.

## How hooks actually work

Hooks live under the `hooks` key in `settings.json` — either user-global at `~/.claude/settings.json` or per-project at `.claude/settings.json`. Each entry is keyed by an **event**: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `Notification`, `SessionStart`, `PreCompact`.

For the tool events you give a `matcher` — a regex tested against the tool name (`Bash`, `Edit`, `Write`, `Edit|Write`) — and a list of `hooks`, each `{ "type": "command", "command": "..." }`. The command runs in your shell.

Claude passes the event payload as **JSON on stdin**: `tool_name`, `tool_input`, `session_id`, and more. You parse it with `jq`. The hook talks back through its **exit code**:

- exit `0` — allow / success.
- exit `2` — block. For `PreToolUse` the tool is cancelled and **stderr is fed back to Claude** as the reason, so it can adjust.
- any other non-zero — non-blocking error, surfaced to you but the run continues.

That exit-code contract is the whole game. Everything below is just three small scripts wired to it. Same instinct as my [PowerShell scripts for Claude](https://blog.sudowl.com/how-i-use-claude-powershell-scripts/) — let the model drive, keep the guardrails in shell.

## Auto-format on every edit

The one I'd never give up. Every time Claude touches a file via `Edit` or `Write`, format it.

`~/.claude/hooks/format.sh`:

```bash
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
[ -z "$file" ] && exit 0
[ -f "$file" ] || exit 0

case "$file" in
  *.go)            gofmt -w "$file" ;;
  *.js|*.ts|*.tsx|*.jsx|*.json|*.md|*.css) npx prettier --write "$file" ;;
  *.py)            ruff format "$file" ;;
  *.sh)            shfmt -w "$file" ;;
esac
exit 0

```

The file path comes straight off `tool_input.file_path`. I match on extension, run the right formatter, exit 0\. Claude sees the formatted result on its next read, so its mental model stays in sync — no drift between what it wrote and what's on disk.

**The exception:** prettier on a huge JSON file adds a second per edit. In repos where that bites, I scope the matcher to a project `.claude/settings.json` and drop `*.json` from the case.

## Block dangerous Bash before it runs

`PreToolUse` on `Bash` is a veto. I inspect the command string and exit 2 on anything I never want executed unattended.

`~/.claude/hooks/guard-bash.sh`:

```bash
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')

deny() { echo "BLOCKED: $1" >&2; exit 2; }

# nuke-the-disk variants
echo "$cmd" | grep -Eq 'rm[[:space:]]+-[a-z]*r[a-z]*f?[[:space:]]+(/|~|\$HOME)([[:space:]]|$)' \
  && deny "recursive force-delete of a root/home path"

# force-push to main
echo "$cmd" | grep -Eq 'git[[:space:]]+push[[:space:]].*--force.*\b(main|master)\b' \
  && deny "force-push to a protected branch"

# piping the web straight into a shell
echo "$cmd" | grep -Eq '(curl|wget)[[:space:]].*\|[[:space:]]*(sudo[[:space:]]+)?(bash|sh)' \
  && deny "curl|wget piped into a shell"

exit 0

```

When this fires, the tool call never happens and the `BLOCKED: ...` line on stderr goes back to Claude as feedback — it reads the reason and usually proposes a safer command on its own. The block is real, not advisory.

**The exception:** this is a seatbelt, not a security boundary. A determined model can phrase around a regex, and I run Claude in a contained environment regardless. The hook stops *accidents* — a stray `rm -rf` assembled from a bad variable — not an adversary. Don't let it talk you out of permission scoping or sandboxing.

## Tell me when it's done

Claude finishes and goes quiet. The `Stop` hook turns that into a desktop ping plus a log line, so I can context-switch away during a long run.

`~/.claude/hooks/notify.sh`:

```bash
#!/usr/bin/env bash
read -r payload
session=$(echo "$payload" | jq -r '.session_id // "?"')
ts=$(date '+%F %T')

echo "$ts  stop  session=$session" >> ~/.claude/run.log

# Linux desktop
command -v notify-send >/dev/null && notify-send "Claude Code" "Run finished"
# macOS
command -v osascript  >/dev/null && \
  osascript -e 'display notification "Run finished" with title "Claude Code"'

exit 0

```

`Stop` doesn't carry a `tool_input` — I just grab `session_id` for the log and fire the notification. The append-only `run.log` is handy on its own: a flat record of every session boundary I can `tail` later. Same `SubagentStop` event exists if you want a ping when a parallel [subagent](https://blog.sudowl.com/claude-code-superpowers-brainstorming/) wraps up instead of the whole run.

## Wiring it up

```bash
mkdir -p ~/.claude/hooks
chmod +x ~/.claude/hooks/*.sh

```

Edit `settings.json`, then run `/hooks` inside Claude Code to confirm everything registered. Changes to the file take effect on the next session — restart if a hook isn't firing.

Keep the scripts in their own files, not inline in the JSON. Inline shell inside a JSON string is a quoting nightmare, and a real file is something you can run by hand to debug. This is the same plumbing I leaned on building a [local MCP server for sysadmin tools](https://blog.sudowl.com/build-mcp-server-sysadmin-tools/) — small shell scripts, well-defined I/O contract, easy to test in isolation.

## Gotchas

- **`jq` must be on PATH.** The hook shell isn't your interactive login shell — it may not source your profile. Use absolute paths or set PATH at the top of the script.
- **Exit 2 only blocks on `PreToolUse` (and a couple of others).** A `PostToolUse` hook exiting 2 won't un-run the tool — the action already happened. Validate *before*, format *after*.
- **Quote `file_path`.** Paths with spaces (hello, Windows) break unquoted `$file`. Always `"$file"`.
- **Hooks run on every match — keep them fast.** A slow formatter on every `Edit` makes the whole session feel sluggish. Scope the matcher tightly.
- **A non-2 non-zero exit is noise, not a stop.** Forget to `exit 0` at the end and a stray status leaks an error into your transcript. End every hook with `exit 0`.
- **User vs project settings both load.** A global format hook plus a project one means the file gets formatted twice. Pick a layer per concern.
- **`jq -r '... // empty'`** — without the `// empty` fallback you get the literal string `null` when a field is missing, and your `[ -z ]` checks pass through `null` paths. Guard every field.

Three scripts, one config block. The formatter alone paid for the setup in a day; the Bash guard is the one that lets me actually walk away.

---

### Related posts

- [Claude Code Superpowers: How I Brainstorm Features Before Writing a Single Line](https://blog.diengdoh.com/claude-code-superpowers-brainstorming/?ref=blog.sudowl.com)
- [Building an MCP Server to Give Claude My Sysadmin Tools](https://blog.diengdoh.com/build-mcp-server-sysadmin-tools/?ref=blog.sudowl.com)
- [How I Use Claude to Write PowerShell Scripts in 30 Seconds](https://blog.diengdoh.com/how-i-use-claude-powershell-scripts/?ref=blog.sudowl.com)