6dce8f4bad79baf852f998bffa42e6b6cd23138e
github-webhook-cron — Event-Driven Wakes for cron-framework
Route GitHub webhook events to cron-framework triggers. Replaces "poll every 10 minutes hoping there's something to do" with "wake up exactly when GitHub tells us to."
Part of The Agent Crafting Table — standalone agent system components for Claude Code.
sequenceDiagram
participant GH as GitHub
participant WS as webhook-server.js (PORT 7456)
participant WH as webhook-handler.js
participant TF as crons/triggers/
participant CR as cron-runner (60s tick)
participant AG as Claude Agent
GH->>WS: POST /webhook (pull_request, push, review...)
WS->>WS: Buffer request body
WS->>WH: Pipe payload via stdin
WH->>WH: Verify HMAC SHA-256 signature
alt invalid signature
WH-->>WS: exit 1
WS-->>GH: 403
else valid
WH->>WH: Look up routing rule in crons/routes.json
alt no rule match
WH-->>GH: 200 {"action":"ignored"}
else rule matched
WH->>TF: write crons/triggers/reviewer.trigger
WH-->>GH: 200 {"action":"triggered"}
CR->>TF: detect trigger file on next 60s tick
CR->>AG: spawn Claude agent for job
AG->>TF: delete trigger file
end
end
flowchart TD
subgraph "Routing Rules (routes.json)"
R1["pull_request.opened → reviewer"]
R2["pull_request.synchronize → reviewer"]
R3["pull_request.closed.merged → merge-watcher"]
R4["pull_request_review.submitted.by_owner → reviewer"]
R5["push.feature_branch → reviewer"]
R6["pull_request (draft) → ignored"]
R7["pull_request_review (non-owner) → ignored"]
end
subgraph "Token Savings"
BEFORE["Before: poll every 10 min
144 polls/day
~720k idle tokens/day"]
AFTER["After: webhook triggers only
~10 fires/day
~50k tokens/day"]
BEFORE -->|"github-webhook-cron"| AFTER
end
Cron agents that watch GitHub (PR reviewers, merge watchers, CI fixers) typically poll on a fixed schedule. ~80% of those polls find nothing to do — they spin a Claude session, list open PRs, see no new state, and exit. On a Max plan that's hundreds of empty token spins per day.
This repo flips it. GitHub fires a webhook on PR open / sync / review / close / push. The handler drops a trigger file at crons/triggers/<jobId>.trigger. The cron-runner picks it up on its next 60s tick and fires the agent only when there's actually something to look at. Empty polls go to zero.
git clone https://github.com/Agent-Crafting-Table/github-webhook-cron.git lib/github-webhooksOr copy src/ into your existing repo. No npm install needed — pure node, no deps.
Copy examples/routes.json to crons/routes.json and edit:
{
"owner_logins": ["yourname"],
"feature_branch_regex": "^refs/heads/feat/",
"rules": {
"pull_request.opened": "reviewer",
"pull_request.ready_for_review": "reviewer",
"pull_request.synchronize": "reviewer",
"pull_request.reopened": "reviewer",
"pull_request.closed.merged": "merge-watcher",
"pull_request_review.submitted.by_owner": "reviewer",
"push.feature_branch": "reviewer"
}
}The values ("reviewer", "merge-watcher") are the cron job IDs you want to wake. The handler writes crons/triggers/<value>.trigger and your cron-runner picks it up.
owner_logins— pull_request_review events from these users trigger the.by_ownerrule. Reviews from anyone else (including bot reviewers you produce yourself) are ignored.feature_branch_regex— push events whose ref matches this regex trigger thepush.feature_branchrule. Pushes to other refs are ignored.rules— keys are dotted event paths. Missing keys → that event is ignored (no error).
Pick a random hex string. Set it on both the GitHub webhook ("Secret" field) and your environment:
export GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32)The handler verifies HMAC SHA-256 via X-Hub-Signature-256. Without a secret, anyone can hit your endpoint and trigger your agents — never run in production without one.
PORT=7456 \
ROUTES_FILE=./crons/routes.json \
WORKSPACE_DIR=$(pwd) \
node src/webhook-server.jsGitHub needs a public URL. Three options:
- Cloudflare Tunnel —
cloudflared tunnel run --url http://localhost:7456 - Reverse proxy behind nginx / caddy with TLS
- ngrok for local dev —
ngrok http 7456
In your repo → Settings → Webhooks → Add webhook:
- Payload URL:
https://your-public-url/webhook - Content type:
application/json - Secret: the value of
GITHUB_WEBHOOK_SECRET - Events: select Pull requests, Pull request reviews, and Pushes
| Event | Condition | Rule key | Default trigger |
|---|---|---|---|
ping |
always | (built-in) | ignored |
pull_request |
action=opened/ready_for_review/synchronize/reopened, not draft |
pull_request.<action> |
(per rule) |
pull_request |
action=closed, merged=true |
pull_request.closed.merged |
(per rule) |
pull_request |
action=closed, merged=false |
(built-in) | ignored |
pull_request |
draft | (built-in) | ignored |
pull_request_review |
action=submitted, sender ∈ owner_logins |
pull_request_review.submitted.by_owner |
(per rule) |
pull_request_review |
sender not in owner_logins |
(built-in) | ignored |
push |
ref matches feature_branch_regex |
push.feature_branch |
(per rule) |
push |
ref does not match | (built-in) | ignored |
| anything else | always | (no rule) | ignored |
cat payload.json | node src/webhook-handler.js \
--event pull_request \
--routes ./crons/routes.json \
--dry-run
# {"action":"dry-run","reason":"pr.opened #12","trigger":"reviewer"}node test/webhook-handler.test.js15 cases — covers every routing branch, signature verification, and the trigger-file write path.
- Always set
GITHUB_WEBHOOK_SECRETin production. Without it the handler will accept any payload. - The trigger files are empty — no risk of payload smuggling.
- The handler runs
JSON.parse+ small string operations; no shell-out, no eval. - The server has no TLS — terminate TLS at your reverse proxy / tunnel.
Rough estimate from running this on a 5-agent fleet:
| Before (10-min poll) | After (webhook) |
|---|---|
| 144 polls/day | ~10 webhook fires/day |
| 144 Claude spins | 10 Claude spins |
| ~720k tokens/day idle reading | ~50k tokens/day idle reading |
Apache-2.0