The Boring Problem Nobody’s Solving #
I’ve been running a personal AI agent for a while now. It handles the kind of work that’s important but tedious — content management, file organization, routine maintenance. The AI part was surprisingly easy to get right. The hard part? Telling it what to do.
The ecosystem has a tool for everything. Code generation, RAG pipelines, vector databases, multi-agent orchestration frameworks with names that sound like sci-fi planets. But when it comes to the most basic question — “how do I assign a task to my agent and track whether it got done?” — the answer is usually a markdown file and a prayer.
That’s where I started. TODO.md. Checkboxes. The agent reads it, finds unchecked items, does the work. Dead simple.
Also, dead broken.
The file lived in a synced vault, so editing from my phone meant opening the app, finding the file, scrolling through 80 lines, and hoping sync didn’t conflict. Every task was a one-liner with zero context. And the agent only checked the file every 30 minutes on a heartbeat, so there was always a gap between “I need this done” and “the agent notices.”
I looked at existing tools — agent dashboards, task UIs, that sort of thing. They all want their own database, their own auth, their own deployment. That’s a lot of infrastructure for “hey, go do this.”
Then I realized I already had exactly what I needed. It has a mobile app, an API, labels, rich descriptions, notifications, threaded comments, and version history. It’s free for private repos.
It’s GitHub Issues.
The Setup #
flowchart TD
A[GitHub Issue created/commented] --> B[GitHub Webhook]
B --> C[Cloudflare Tunnel]
C --> D[WAF — GitHub IPs only]
D --> E[Go Interceptor]
E --> F[Agent Gateway — loopback]
F --> G[Agent wakes up]
I have a few private repos — one for general task management, others for specific projects. Each repo has a webhook. When I create or comment on an issue, GitHub fires a POST. That hits a Cloudflare Tunnel, passes through a WAF rule that only allows GitHub’s IP ranges, gets validated by a small Go service, and is forwarded to the agent gateway on localhost.
The agent wakes up in about two seconds.
Why This Works Better Than Everything Else I Tried #
GitHub Issues aren’t designed for this. That’s what makes them good. They’re a mature, battle-tested system that happens to fit the problem perfectly:
Context lives with the task. Not in a one-liner. I can write a full description, attach screenshots, add checklists, link to commits. The agent gets everything it needs without asking follow-up questions.
Labels replace categories. I tag issues by type, owner, and project. The agent filters by its own label to know what’s relevant.
Comments are the feedback loop. The agent comments when it finishes. I comment if I want changes. It’s threaded, timestamped, and I can read it from my phone.
The mobile app is genuinely good. I create issues while commuting. By the time I look at my phone again, the agent has already commented with the result.
Audit trail is automatic. Who opened it, who closed it, when, what changed. Git gives you this for free.
Security #
If you expose an AI agent to the internet — even through a webhook — you need to take security seriously. The agent has filesystem access, shell access, API tokens. An open webhook endpoint is an invitation.
These layers are all mandatory:
Private repos #
If the repo is public, strangers can create issues and trigger your agent with whatever payload they want. The HMAC signature will be valid — because it came from GitHub. It’s just not from you.
Every repo that fires webhooks at your agent must be private. Verify after creation. Don’t tell yourself you’ll fix it later.
IP allowlisting #
GitHub publishes their webhook IP ranges at https://api.github.com/meta (the hooks field). Put them in a WAF rule. Everything else gets a 403.
192.30.252.0/22
185.199.108.0/22
140.82.112.0/20
143.55.64.0/20
HMAC validation #
GitHub signs every webhook payload with X-Hub-Signature-256. The interceptor validates the signature before forwarding. No valid signature, no forwarding.
flowchart LR
A[Request] --> B{GitHub IP?}
B -->|No| C[403]
B -->|Yes| D{Valid HMAC?}
D -->|No| E[401]
D -->|Yes| F[Forward]
Per-IP rate limiting #
Even valid traffic can be abused. The interceptor uses golang.org/x/time/rate to enforce a per-IP token bucket on the /github endpoint. A burst of requests from the same source gets throttled with 429 Too Many Requests instead of hammering the gateway.
Replay cache #
GitHub includes a unique delivery ID in X-GitHub-Delivery. The interceptor keeps a short TTL cache of seen IDs and rejects duplicates with 409 Conflict. That limits the window in which a valid, signed request can be replayed.
Bind to loopback by default #
The default listen address is 127.0.0.1:19876. If you override it to bind a non-loopback address, the service logs a warning reminding you that it must sit behind a trusted TLS-terminating reverse proxy or tunnel. The interceptor is the only thing the tunnel sees. The gateway stays on loopback.
No redirect following #
The shared HTTP client refuses to follow redirects. If the gateway ever misconfigured a redirect, the interceptor returns the redirect response as-is instead of chasing it and potentially leaking the Bearer token to a third party.
The Go Interceptor #
GitHub webhooks use HMAC signatures. Most agent gateways expect Bearer tokens. Different auth mechanisms. Something needs to sit between them.
I wrote the interceptor in around 500 lines of Go, including tests. It has one small external dependency — golang.org/x/time/rate for rate limiting — and go test reports about 82% coverage. It compiles to a single binary.
It does a few things:
- Validates the HMAC signature
- Rate-limits per IP and deduplicates delivery IDs
- Parses the JSON payload into typed structs
- Extracts event context into a readable message
- Forwards to the gateway with a Bearer token, using the incoming request’s context
The core types look like this:
type issueEvent struct {
Action string `json:"action"`
Issue issueDetails `json:"issue"`
Sender sender `json:"sender"`
}
type issueDetails struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"html_url"`
Labels []label `json:"labels"`
}
type label struct {
Name string `json:"name"`
}
The handler enforces size limits and signature checks before parsing:
func readAndVerifyBody(w http.ResponseWriter, r *http.Request, secret string) ([]byte, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
status := http.StatusBadRequest
if isTooLarge(err) {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, "failed to read body", status)
return nil, false
}
sig := r.Header.Get("X-Hub-Signature-256")
if !validateSignature(body, sig, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return nil, false
}
return body, true
}
A representative test uses httptest to stand in for the gateway and verify the full forward path:
func TestHandleGitHubForwardsIssue(t *testing.T) {
secret := "secret"
gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hooks/agent" {
t.Errorf("gateway path = %q", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer gateway-token" {
t.Errorf("Authorization header = %q", auth)
}
w.WriteHeader(http.StatusOK)
}))
defer gateway.Close()
cfg := Config{
GitHubSecret: secret,
GatewayURL: gateway.URL,
GatewayToken: "gateway-token",
}
client := gateway.Client()
payload := issueEvent{
Action: "opened",
Issue: issueDetails{Number: 1, Title: "Test", Body: "body"},
}
body, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
sig := "sha256=" + hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/github", bytes.NewReader(body))
req.Header.Set("X-GitHub-Event", "issues")
req.Header.Set("X-GitHub-Delivery", "123e4567-e89b-12d3-a456-426614174000")
req.Header.Set("X-Hub-Signature-256", sig)
rec := httptest.NewRecorder()
handleGitHub(rec, req, cfg, client)
if rec.Code != http.StatusOK {
t.Errorf("forward status = %d, want %d", rec.Code, http.StatusOK)
}
}
The shared client is configured once and reused, so connections are pooled and redirects are disabled:
gatewayClient := &http.Client{
Timeout: gatewayTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
When it forwards, it carries the incoming request’s context so a cancelled webhook also cancels the outbound call:
req, err := http.NewRequestWithContext(
r.Context(),
http.MethodPost,
gatewayURL,
bytes.NewReader(payloadBytes),
)
The message it builds looks like this:
[GitHub Issue #12 opened] Fix the SEO descriptions
By: username
Labels: content, agent
URL: https://github.com/<owner>/<repo>/issues/12
The about page is missing meta descriptions. Add them.
The agent reads it like a human would. No JSON parsing in the prompt. The interceptor handles the translation.
Why Go and not a Cloudflare Worker? A binary on my machine is platform-independent, has minimal external dependencies, and means the gateway never needs to be directly accessible. The interceptor is the only thing the tunnel sees. The gateway stays on loopback.
Download #
Pre-built binaries are available from the GitHub Releases page for macOS, Linux, and Windows, both amd64 and arm64.
To grab the latest release from the command line:
# macOS (Apple Silicon)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-darwin-arm64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
# macOS (Intel)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-darwin-amd64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
# Linux (x86_64)
curl -L -o github-webhook-interceptor.tar.gz https://github.com/daveamit/github-webhook-interceptor/releases/latest/download/github-webhook-interceptor-linux-amd64.tar.gz
tar -xzf github-webhook-interceptor.tar.gz
Running it #
Four environment variables. No config files.
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
127.0.0.1:19876 |
Address the interceptor listens on |
GATEWAY_URL |
http://localhost:18789 |
Agent gateway URL |
GITHUB_SECRET |
required | GitHub webhook secret for HMAC validation |
GATEWAY_TOKEN |
required | Bearer token for the agent gateway |
export GITHUB_SECRET="your-webhook-secret"
export GATEWAY_TOKEN="your-gateway-token"
export GATEWAY_URL="http://localhost:18789"
export LISTEN_ADDR="127.0.0.1:19876"
./github-webhook-interceptor
For a one-off test this is enough. For a machine that should always forward webhooks, run it as a service.
Installation #
The binary is small, single-file, and has no runtime dependencies. I install it like a self-managed package: one binary in a known path and a service file that owns the process. No brew install. No apt install. For a tool that holds my webhook secret and gateway token, I want the file path and the service to be obvious, inspectable, and easy to rip out.
All paths and names below are placeholders. Swap in your own binary name, username, paths, secret, and token.
macOS: LaunchAgent plist #
A LaunchAgent runs as the logged-in user, starts at login, and survives sleep. That is the right layer for a laptop or desktop that is always logged in. If you need the interceptor to run before anyone logs in, use a LaunchDaemon in /Library/LaunchDaemons/ instead; the plist is nearly identical but requires root.
Put the binary somewhere permanent. ~/.local/bin/ is fine:
mkdir -p ~/.local/bin
cp <binary-name> ~/.local/bin/
chmod 755 ~/.local/bin/<binary-name>
Create ~/Library/LaunchAgents/com.example.stdout-feed.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.example.stdout-feed</string>
<key>ProgramArguments</key>
<array>
<string>/Users/<username>/.local/bin/<binary-name></string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GITHUB_SECRET</key>
<string>your-webhook-secret</string>
<key>GATEWAY_TOKEN</key>
<string>your-gateway-token</string>
<key>GATEWAY_URL</key>
<string>http://localhost:18789</string>
<key>LISTEN_ADDR</key>
<string>127.0.0.1:19876</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/<log-prefix>.log</string>
<key>StandardErrorPath</key>
<string>/tmp/<log-prefix>.log</string>
</dict>
</plist>
Load it under the current user’s GUI domain. The service starts immediately because RunAtLoad is true:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.stdout-feed.plist
If you are on an older macOS that does not have bootstrap, the legacy commands still work:
launchctl load ~/Library/LaunchAgents/com.example.stdout-feed.plist
launchctl start com.example.stdout-feed
To stop or reconfigure, unload first:
launchctl bootout gui/$(id -u)/com.example.stdout-feed
# older macOS:
# launchctl unload ~/Library/LaunchAgents/com.example.stdout-feed.plist
After editing the plist or environment variables, reload it:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.stdout-feed.plist
Logs go to /tmp/<log-prefix>.log. You can also stream the unified log for the process:
log stream --predicate 'process == "<binary-name>"' --info
Linux: systemd unit #
On Linux, systemd is the obvious choice. Place the binary at a fixed system path — /usr/local/bin/ is the conventional one — and create /etc/systemd/system/stdout-feed.service:
[Unit]
Description=stdout feed webhook interceptor
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/<binary-name>
Restart=on-failure
RestartSec=5
Environment="GITHUB_SECRET=your-webhook-secret"
Environment="GATEWAY_TOKEN=your-gateway-token"
Environment="GATEWAY_URL=http://localhost:18789"
Environment="LISTEN_ADDR=127.0.0.1:19876"
[Install]
WantedBy=multi-user.target
Reload systemd, enable the unit so it starts on boot, and start it now in one command:
sudo systemctl daemon-reload
sudo systemctl enable --now stdout-feed
Check status and logs:
sudo systemctl status stdout-feed
sudo journalctl -u stdout-feed -f
Restart=on-failure brings it back up if it crashes. If you change the unit file or environment variables, run:
sudo systemctl daemon-reload
sudo systemctl restart stdout-feed
Either way, the result is the same: a small binary on disk and a service that owns it. The gateway stays on loopback, and the interceptor is the only thing the tunnel sees.
The Whole Flow #
- Create a private repo
- Set up a tunnel pointing to the interceptor
- Add a WAF rule for GitHub’s IPs
- Deploy the interceptor with the shared secret
- Add the webhook on the repo
- Create an issue. Watch the agent wake up.
Took about two hours end to end. Most of that was figuring out the HMAC vs Bearer token mismatch.
Now I open an issue from my phone:
Fix the experience count on the about page Resume says 16 years, blog says “nearly two decades.”
The agent picks it up, makes the edit, commits, pushes, and comments on the issue. Done before I put my phone back in my pocket.
Source code: github.com/daveamit/github-webhook-interceptor