
If you're using Claude Code's pre/post hooks as simple before-and-after scripts, you're leaving most of the power on the table. Drop a message queue between a preToolUse hook and a postToolUse hook, and a single file-write event fans out to linting, testing, Slack notifications, and logging — all without blocking Claude's next move.
This is the event bus pattern applied to Claude Code hooks, and it's straightforward to wire up.
The Problem: Synchronous Hooks Kill Claude's Throughput
The default mental model for hooks is "run a script before, run a script after." That works fine for a single check. The moment you chain lint + test + notification into the same hook, you're making Claude wait on all of it synchronously.
On my Mac Mini 4 running n8n 2.8.4, calling a linter and a Slack notification directly from a postToolUse hook introduced 2.1 seconds of blocking per file write. With even moderate activity — say, Claude refactoring several files in sequence — that adds up fast, and the pipeline of hooks grows linearly with the number of integrations.
The root issue: Claude is acting as its own delivery driver. It starts the job, runs all the downstream work itself, then finally moves on. The fix is to give it a drop-off point.
The Fix: Hooks Publish, a Consumer Process Subscribes
The core insight is a strict separation of concerns:
- Hooks write to a queue. That's their only job.
- A separate consumer process reads the queue and runs the actual work.
Claude posts an event and moves on in under 100ms. The consumer handles everything else asynchronously.
Step 1: Register the Hooks in .claude/settings.json
{
"hooks": {
"preToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 /opt/hooks/pre_publish.py"
}
]
}
],
"postToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 /opt/hooks/post_publish.py"
}
]
}
]
}
}
The matcher: "Write" key means these hooks only fire on file write operations — not on bash commands or other tool calls. Scope your matchers tightly; broad matchers on busy sessions create noisy queues.
Step 2: The Post-Hook Writes to the Queue
The post hook reads the event payload from stdin (Claude Code injects it automatically) and drops a JSON file into a watched directory.
# post_publish.py — writes a single event file, then exits immediately
import json, sys, pathlib, time
queue_dir = pathlib.Path('/tmp/hook_queue')
queue_dir.mkdir(exist_ok=True)
payload = json.loads(sys.stdin.read())
event = {
'ts': time.time(),
'tool': payload.get('tool_name'),
'file': payload.get('tool_input', {}).get('file_path', ''),
}
(queue_dir / f"{event['ts']}.json").write_text(json.dumps(event))
This script finishes in well under 100ms. Claude sees it as "done" and immediately proceeds.
Step 3: The Consumer Polls and Processes
Run this as a persistent background process — more on daemonizing it below.
# consumer.py — polls the queue and runs downstream work
import json, pathlib, subprocess, time
queue_dir = pathlib.Path('/tmp/hook_queue')
while True:
for f in sorted(queue_dir.glob('*.json')):
event = json.loads(f.read_text())
if event.get('file'):
subprocess.run(['ruff', 'check', event['file']])
# Add Slack notification, test runner, etc. here
f.unlink()
time.sleep(0.5)
After switching to this pattern, Claude's blocking time dropped from 2.1 seconds to under 0.08 seconds per file write — a 26× improvement measured under real workloads.
Verification
# In one terminal, tail the queue directory
watch -n 0.5 'ls -lth /tmp/hook_queue/'
# In another, run the consumer manually
python3 /opt/hooks/consumer.py
# Then trigger Claude to write a file and watch the queue drain
You should see event files appear and disappear within half a second of each write.
Variations and Gotchas
Deduplication Is Non-Negotiable
Async pipelines fall apart at ordering and duplication. If Claude writes the same file twice in quick succession, you'll get two lint runs and two Slack pings. The fix is a dedup key based on (file_path + floor(timestamp, 1s)).
# consumer.py — with dedup
import json, pathlib, subprocess, time
queue_dir = pathlib.Path('/tmp/hook_queue')
processed_keys = set()
while True:
for f in sorted(queue_dir.glob('*.json')):
event = json.loads(f.read_text())
key = (event.get('file', ''), int(event.get('ts', 0)))
if key not in processed_keys:
processed_keys.add(key)
if event.get('file'):
subprocess.run(['ruff', 'check', event['file']])
f.unlink()
time.sleep(0.5)
In a four-machine Mac Mini cluster pushing 30+ file events per second, this brought duplicate Slack notifications from "constant annoyance" to zero.
Don't let processed_keys grow forever — trim it to the last N entries or use a TTL cache if your session runs for hours.
Fan-Out: Multiple Consumers on One Queue
Nothing stops you from running two or three separate consumer scripts against the same queue directory. Each reads the same event files and does different work.
The gotcha here: if multiple consumers all call f.unlink(), only one will succeed — the rest will hit a FileNotFoundError. Wrap unlinking in a try/except, or use a "processed" subdirectory pattern instead of deletion.
processed_dir = queue_dir / 'done'
processed_dir.mkdir(exist_ok=True)
# Instead of f.unlink():
f.rename(processed_dir / f.name)
Daemonizing the Consumer
# macOS — launchd plist at ~/Library/LaunchAgents/com.seunghyeon.hook-consumer.plist
cat <<EOF > ~/Library/LaunchAgents/com.seunghyeon.hook-consumer.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.seunghyeon.hook-consumer</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/opt/hooks/consumer.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
EOF
launchctl load ~/Library/LaunchAgents/com.seunghyeon.hook-consumer.plist
# Linux — systemd unit
sudo tee /etc/systemd/system/hook-consumer.service <<EOF
[Unit]
Description=Claude Code Hook Consumer
[Service]
ExecStart=/usr/bin/python3 /opt/hooks/consumer.py
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now hook-consumer
File Queue vs. Redis Streams
| Approach | Latency | Durability | Fan-out | Overhead |
|---|---|---|---|---|
/tmp file queue |
~0 | Lost on reboot | Manual (rename trick) | Zero |
| Redis Streams | ~1ms | Configurable | Native consumer groups | Redis process |
| SQLite table | ~2ms | Persistent | Polling per consumer | None |
For a single-machine dev setup, the file queue is good enough and has zero dependencies. Move to Redis Streams if you're running hooks across multiple machines or need guaranteed delivery after crashes.
Environment Differences
- macOS:
/tmpis per-boot and RAM-backed. Fast, but events vanish on restart. Use~/Library/Application Support/hook_queue/if you want persistence. - Linux/Docker:
/tmpbehaves the same way — ephemeral. Mount a volume in Docker if the consumer lives in a separate container. - Windows (WSL2): The file queue works fine, but
launchdis macOS-only. Use a.batscript with Task Scheduler or asystemdunit inside WSL.
Closing
The architecture is intentionally minimal: hooks write, consumers read, Claude never waits. Adding a new integration means adding a new consumer script, not touching the hook configuration at all.
If you're already running n8n or any workflow engine locally, the consumer can be a webhook trigger instead of a polling loop — the queue file write becomes an HTTP POST to n8n, and you get a full visual workflow editor for free.
Next up: wiring Redis Streams into this pattern for multi-machine hook fan-out with guaranteed delivery.
🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Advanced
💌 Subscribe: Follow on X or grab the RSS
댓글
댓글 쓰기