DEV Community

Nox Craft
Nox Craft

Posted on

I automated my entire dev workflow with Claude Code hooks

Most people use Claude Code as a smarter terminal assistant.
Type a request, read the response, approve the changes.
That's fine, but it leaves a lot of capability on the table.

Claude Code has a hook system that wires the AI directly into your existing workflow:
your formatter, your test runner, your notification system.
We've been running hooks in production-style setups for a few months now
and the interaction model genuinely changes when the tool stops being conversational
and starts being ambient.

Here's what we actually run and why.


What hooks are

Hooks are defined in ~/.claude/settings.json under the hooks key.
Each hook fires at a lifecycle event and runs a shell command.

The four events that matter:

  • PreToolUse -- fires before Claude runs a tool (file write, bash command, etc.)
  • PostToolUse -- fires after a tool completes
  • Notification -- fires when Claude sends status updates
  • Stop -- fires when Claude finishes a response

Hooks can be filtered by tool name, so you can target Bash separately from Write and Edit.

The basic structure:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write",
        "hooks": [
          {
            "type": "command",
            "command": "your-command-here"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Hook 1: Auto-format on file write

Every time Claude writes or edits a file, format it immediately.
This keeps diffs clean and removes a whole category of review noise.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'FILE=$(echo $CLAUDE_TOOL_OUTPUT | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get(\\\"filePath\\\",\\\"\\\"))\" 2>/dev/null); if [ -n \"$FILE\" ]; then case \"$FILE\" in *.rs) cargo fmt -- \"$FILE\" 2>/dev/null;; *.py) ruff format \"$FILE\" 2>/dev/null;; *.ts|*.tsx|*.js) npx prettier --write \"$FILE\" 2>/dev/null;; esac; fi'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The hook reads the file path from the tool output, detects the extension, and runs the right formatter.
Silent on error (2>/dev/null) so it doesn't interrupt the session
if a formatter isn't installed.

For Rust specifically, we also add a cargo check after writes.
This catches type errors while Claude is still in context and can fix them in the same pass:

{
  "matcher": "Write|Edit",
  "hooks": [
    {
      "type": "command",
      "command": "bash -c 'FILE=$(echo $CLAUDE_TOOL_OUTPUT | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get(\\\"filePath\\\",\\\"\\\"))\" 2>/dev/null); if echo \"$FILE\" | grep -q \"\\.rs$\"; then cargo check 2>&1 | tail -5; fi'"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Hook 2: Security scan before bash commands

Before Claude runs any shell command, scan for patterns worth flagging:
piping to sh/bash from curl, rm -rf without bounds, writes to /etc/.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'CMD=$(echo $CLAUDE_TOOL_INPUT | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get(\\\"command\\\",\\\"\\\"))\" 2>/dev/null); RISKY=0; echo \"$CMD\" | grep -qE \"curl.*\\|.*(bash|sh)\" && RISKY=1; echo \"$CMD\" | grep -qE \"rm -rf /[^t]\" && RISKY=1; echo \"$CMD\" | grep -q \"/etc/\" && RISKY=1; if [ $RISKY -eq 1 ]; then echo \"[hook] high-risk command flagged -- review before proceeding\"; fi'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This doesn't block the command.
It prints a visible warning in the output so you catch it when skimming.
We've caught a few genuine mistakes this way -- not AI hallucinations,
just cases where a reasonable command had an unexpected side effect in context.


Hook 3: Desktop notification when Claude finishes

Switch to another window during a long task and you lose track of when it's done.
The Stop hook fires on response completion.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "notify-send 'Claude Code' 'Done.' --icon=terminal --urgency=low 2>/dev/null || true"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

On Ubuntu this uses notify-send.
On macOS, swap it for osascript -e 'display notification "Done." with title "Claude Code"'.

For remote machines, we send to a Discord webhook instead:

{
  "type": "command",
  "command": "bash -c 'source ~/.secrets; curl -s -X POST \"$DISCORD_WEBHOOK_DEV\" -H \"Content-Type: application/json\" -d \"{\\\"content\\\": \\\"Claude finished a task\\\"}\" > /dev/null'"
}
Enter fullscreen mode Exit fullscreen mode

Hook 4: Custom status line with git context

The Notification hook fires when Claude sends progress updates (tool names, status messages).
We use it to set the terminal title to show current branch and last action:

{
  "hooks": {
    "Notification": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'BRANCH=$(git branch --show-current 2>/dev/null || echo \"no-git\"); MSG=$(echo $CLAUDE_NOTIFICATION | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get(\\\"message\\\",\\\"\\\")[:40])\" 2>/dev/null); printf \"\\033]0;claude [%s] %s\\007\" \"$BRANCH\" \"$MSG\"'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The terminal title becomes something like claude [main] Writing src/main.rs.
When you have multiple Claude sessions across different projects, this is the only sane way
to tell them apart in your taskbar.


Hook 5: Test runner on source file changes

For TDD-style work, tests should run automatically after Claude modifies source files.
The tight feedback loop matters: Claude writes code, tests run, Claude sees the result
in the same context window and can iterate without prompting.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'FILE=$(echo $CLAUDE_TOOL_OUTPUT | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get(\\\"filePath\\\",\\\"\\\"))\" 2>/dev/null); if echo \"$FILE\" | grep -qE \"\\.(rs|py|ts)$\" && ! echo \"$FILE\" | grep -qE \"(test|spec)\"; then echo \"[hook] running tests...\"; if [ -f Cargo.toml ]; then cargo test 2>&1 | tail -10; elif [ -f pyproject.toml ]; then python -m pytest -x -q 2>&1 | tail -10; fi; fi'"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Skips test files themselves to avoid infinite loops.
Detects project type by manifest file.
Tails 10 lines so the output stays readable.


Putting it together

The full hooks section in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "..." }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          { "type": "command", "command": "..." },
          { "type": "command", "command": "..." }
        ]
      }
    ],
    "Notification": [
      {
        "hooks": [{ "type": "command", "command": "..." }]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "..." }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Multiple hooks can fire for the same event and run in sequence.


Caveats worth knowing

PreToolUse hooks run synchronously -- a slow pre-hook adds latency before every tool call.
Keep them under 100ms or use them sparingly.

PostToolUse hooks run after the tool completes, so slow post-hooks don't block Claude.
They just add noise to the output, which is usually fine.

If a hook exits non-zero, Claude sees the output but continues.
By default hooks are advisory, not blocking.
There's a blocking mode available for PreToolUse,
but we haven't needed it -- the warning output is enough.


The hook system is underused because it's not obvious that it exists.
Once you wire Claude into your actual toolchain, the conversational back-and-forth
collapses into something closer to a fast pair programmer who runs your checks automatically.

The full config is in a public gist at github.com/noxcraftdev if you want a starting point.

Happy coding!

Top comments (0)