Nox CraftMost people use Claude Code as a smarter terminal assistant. Type a request, read the response,...
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.
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 completesNotification -- fires when Claude sends status updatesStop -- fires when Claude finishes a responseHooks 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"
}
]
}
]
}
}
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'"
}
]
}
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.
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'"
}
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.
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.
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.
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!