A watcher and webhook relay that pushes notifications into running Claude Code sessions. Any script that can print to stdout — or any system that can fire an HTTP POST — can send messages directly into your Claude conversation.
Watchers: Script stdout ──> Channel ──> Router ──> Channel ──> Claude Code
Webhooks: HTTP POST ──> Router ──> Channel ──> Claude Code
The CLI (hh) sets everything up. Each Claude Code session runs a channel (MCP server) that auto-registers with a local router. Watchers are scripts that run on an interval — their stdout becomes notifications. The router also accepts webhooks from external systems and forwards them by project_slug.
npm install -g hookheraldRequires Node.js >= 18.
# 1. Start the router
hh router --bg
# 2. Set up a project
cd ~/my-project
hh init
# 3. Start Claude Code (from the same directory — it needs .mcp.json and .hookherald.json)
claude --dangerously-load-development-channels server:webhook-channelhh init creates .hookherald.json with an empty watchers array and .mcp.json for the channel. Edit .hookherald.json to add watchers:
{
"slug": "mygroup/myapp",
"router_url": "http://127.0.0.1:9000",
"watchers": [
{ "command": "./check-pipeline.sh", "interval": 30 },
{ "command": "kubectl get pods -n default -o json", "interval": 60 }
]
}Watchers poll external systems and push notifications into Claude Code. The contract is simple — HookHerald runs your command and forwards whatever it prints:
- stdout = send — any non-empty stdout gets forwarded to Claude Code as a notification
- no stdout = skip — nothing happens, no notification
- exit code doesn't matter — only stdout counts, output is captured even on non-zero exit
- JSON stdout is parsed — valid JSON stays structured, plain text stays as a string
- No diffing — HookHerald doesn't compare outputs between runs. If your script prints something, it gets sent. The script decides when to fire and handles its own state/dedup.
#!/bin/bash
# check-ci.sh — notify on GitHub Actions status changes
STATE_FILE="/tmp/hh-ci-state"
REPO="myorg/myrepo"
RUN=$(gh run list --repo "$REPO" --limit 1 --json databaseId,status,conclusion,headBranch,event,createdAt,url 2>/dev/null | jq '.[0]')
[ -z "$RUN" ] || [ "$RUN" = "null" ] && exit 0
KEY=$(echo "$RUN" | jq -r '[.databaseId, .status, .conclusion] | join(":")')
LAST=$(cat "$STATE_FILE" 2>/dev/null)
[ "$KEY" = "$LAST" ] && exit 0
echo "$KEY" > "$STATE_FILE"
echo "$RUN"#!/bin/bash
# watch-pods.sh — notify when pod states change
STATE_FILE="/tmp/hh-pods-state"
CURRENT=$(kubectl get pods -n default -o json 2>/dev/null | jq -c \
'[.items[] | {name: .metadata.name, phase: .status.phase, ready: (.status.containerStatuses // [] | map(.ready) | all), restarts: (.status.containerStatuses // [] | map(.restartCount) | add // 0)}] | sort_by(.name)')
if [ -z "$CURRENT" ] || [ "$CURRENT" = "[]" ]; then exit 0; fi
LAST=$(cat "$STATE_FILE" 2>/dev/null)
if [ "$CURRENT" = "$LAST" ]; then exit 0; fi
echo "$CURRENT" > "$STATE_FILE"
echo "$CURRENT" | jq '{
pods: .,
summary: {
total: (. | length),
running: ([.[] | select(.phase == "Running")] | length),
not_ready: ([.[] | select(.ready == false)] | length),
crashing: ([.[] | select(.restarts > 3)] | length)
}
}'#!/bin/bash
# check-pipeline.sh — notify on pipeline completion
STATE_FILE="/tmp/hh-pipeline-last"
PIPELINE=$(curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.com/api/v4/projects/mygroup%2Fmyapp/pipelines/latest")
ID=$(echo "$PIPELINE" | jq -r '.id')
STATUS=$(echo "$PIPELINE" | jq -r '.status')
LAST=$(cat "$STATE_FILE" 2>/dev/null)
case "$STATUS" in
failed|success|canceled)
[ "$ID" = "$LAST" ] && exit 0
echo "$ID" > "$STATE_FILE"
echo "$PIPELINE" | jq '{
pipeline_id: .id,
status: .status,
ref: .ref,
url: .web_url
}'
;;
esacEdit .hookherald.json while Claude Code is running — watchers are added/removed automatically. No restart needed. The dashboard updates within 30 seconds.
See examples/README.md for detailed walkthroughs, more scripts, writing your own watchers, and troubleshooting.
The router also accepts webhooks from external systems. Any HTTP POST with a project_slug field gets forwarded to the matching channel:
curl -X POST http://127.0.0.1:9000/ \
-H "Content-Type: application/json" \
-d '{"project_slug":"my-group/my-project","status":"deployed","version":"1.2.3"}'No auth by default — localhost is trusted. See Auth to enable it.
hh init [--slug <slug>] [--router-url <url>] Set up .mcp.json + .hookherald.json
hh status [--router-url <url>] Show active sessions
hh kill <slug> [--router-url <url>] Bounce a session
hh router [--port <port>] [--secret <secret>] Start the webhook router
[--bg] Run in background
hh router stop Stop background router
hh init auto-detects the project slug from git remote origin. Creates .hookherald.json with an empty watchers array. Merges with existing .mcp.json if present. Won't overwrite an existing .hookherald.json.
hh kill signals the channel to shut down. Claude Code will respawn it.
Auth is opt-in. By default, no secret is needed — everything runs on localhost.
# No auth (default)
hh router
# Enable auth on webhook ingestion
hh router --secret my-secretWhen a secret is set, POST / requires X-Webhook-Token (or X-Gitlab-Token). Internal endpoints (/register, /unregister, /api/kill) never require auth.
For external sources (GitLab CI, GitHub Actions), start the router with --secret and configure the same secret in the webhook settings.
The router can also run via Docker (requires --network host on Linux):
docker run -d --network host shoofio/hookherald
# With auth
docker run -d --network host -e WEBHOOK_SECRET=my-secret shoofio/hookheraldFor rootless Docker or Docker Desktop (Mac/Windows), use
hh routerinstead.
Live session management UI at http://127.0.0.1:9000/:
- Sessions — status, events, errors, latency, kill button
- Watchers — shown per session with command and interval, click to filter events by source
- Events — click sessions to filter, ctrl/shift for multi-select, expand for trace waterfall and payload
- Live — SSE-powered, no refresh needed
| Method | Path | Description |
|---|---|---|
POST |
/ |
Receive webhook (auth required only if WEBHOOK_SECRET is set) |
POST |
/api/kill |
Remove a session (signals channel to shut down) |
GET |
/ |
Dashboard |
GET |
/api/health |
Health check |
GET |
/api/sessions |
Active sessions with metrics and watchers |
GET |
/api/events |
Query events (?slug=, ?limit=, ?offset=) |
GET |
/api/events/:id |
Single event by ID |
GET |
/api/stream |
SSE live updates |
GET |
/metrics |
Prometheus format |
| Variable | Default | Description |
|---|---|---|
ROUTER_PORT |
9000 |
Router listen port |
ROUTER_HOST |
127.0.0.1 |
Bind address (0.0.0.0 for Docker) |
WEBHOOK_SECRET |
(none) | Auth secret (opt-in) |
PROJECT_SLUG |
unknown/project |
Channel's project ID |
ROUTER_URL |
http://127.0.0.1:9000 |
Router address |
LOG_LEVEL |
info |
debug/info/warn/error |
HH_HEARTBEAT_MS |
30000 |
Channel heartbeat interval |
HH_CONFIG_PATH |
(none) | Path to .hookherald.json (set by hh init) |
MIT