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"
}
]
}
]
}
}
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'"
}
]
}
]
}
}
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'"
}
]
}
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'"
}
]
}
]
}
}
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"
}
]
}
]
}
}
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'"
}
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\"'"
}
]
}
]
}
}
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'"
}
]
}
]
}
}
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": "..." }]
}
]
}
}
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)