Claude Code Hooks as an Event Bus: Building Async Pipelines

hero

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.

overall pipeline flow


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.

synchronous hook bottleneck

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.

async queue flow

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.

fan-out consumer pattern

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.

queue backend tradeoffs

Environment Differences

  • macOS: /tmp is per-boot and RAM-backed. Fast, but events vanish on restart. Use ~/Library/Application Support/hook_queue/ if you want persistence.
  • Linux/Docker: /tmp behaves 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 launchd is macOS-only. Use a .bat script with Task Scheduler or a systemd unit 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

댓글