AI Agents·8 min read·

Webhook-Triggered Agents from GitHub Events

Verify GitHub HMAC signatures, enqueue the JSON payload, and let a worker agent comment or label, so a webhook never blocks on a model API call.

NB

Netbay Developer Relations

Netbay Engineering

On this page

GitHub will POST issues, pull requests, and workflow_run events to any URL you publish. That URL is not an agent. If you call a model inside the request handler you will miss GitHub's timeout, retry the same ticket three times, and pay for the retries. The production shape is a thin verifier that checks the HMAC, writes the envelope, and returns 202. A worker you already run consumes the envelope and maybe comments.

This post wires that path on one Lucknow VPS: nginx or Caddy in front, a Python listener bound to localhost, systemd, and a GitHub App private key that can only label and comment. Intel Xeon Platinum is idle during the HMAC. L3/L4 filtering helps with volumetric noise; it does not replace signature checks.

Verify first, think later

GitHub signs the body with X-Hub-Signature-256. Compare with hmac.compare_digest. Reject missing signatures, reject unknown events, reject bodies over a cap. Then persist. Do not parse the issue body into a prompt on the request thread.

python
# /opt/agents/github_hook.py
import hmac, hashlib, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from envelope import put

SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
MAX = 64 * 1024
ALLOW = {"issues", "issue_comment", "pull_request"}

class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        if length <= 0 or length > MAX:
            self.send_error(413)
            return
        body = self.rfile.read(length)
        sig = self.headers.get("X-Hub-Signature-256", "")
        digest = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(digest, sig):
            self.send_error(401)
            return
        event = self.headers.get("X-GitHub-Event", "")
        if event not in ALLOW:
            self.send_response(204)
            self.end_headers()
            return
        payload = json.loads(body.decode("utf-8"))
        put("github_event", {"event": event, "payload": payload}, "hook", "triage_worker")
        self.send_response(202)
        self.end_headers()

HTTPServer(("127.0.0.1", 8091), Hook).serve_forever()

Return 204 for events you ignore so GitHub does not retry. Return 202 only when the envelope is on disk. Delivery retries are GitHub's problem after that; duplicates are yours. Dedupe on delivery X-GitHub-Delivery, not on issue number, because a second comment is a new event.

The worker must be a GitHub App, not a PAT

A personal access token is a person. When it leaks, it is that person's repos and often their org. A GitHub App installation token is scoped to the repos you selected and the permissions you ticked: issues write, metadata read, nothing else. Generate the short-lived installation token from the App's private key at the start of each job, not at boot.

python
# /opt/agents/github_comment.py
import json, os, time, urllib.request
import jwt  # PyJWT, RS256

def installation_token(app_id, inst_id, pem_path):
    now = int(time.time())
    token = jwt.encode(
        {"iat": now - 30, "exp": now + 540, "iss": app_id},
        open(pem_path).read(),
        algorithm="RS256",
    )
    req = urllib.request.Request(
        "https://api.github.com/app/installations/" + str(inst_id) + "/access_tokens",
        data=b"",
        method="POST",
        headers={
            "Authorization": "Bearer " + token,
            "Accept": "application/vnd.github+json",
        },
    )
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode())["token"]

The PEM lives in /etc/agents/github.pem, mode 0400, owner agent-hook. The webhook secret is a different file. Neither belongs in the repo or in a compose file checked into git. If the worker is allowed to comment, it is not allowed to push.

What the agent may do with an issue

Default: label and write a draft comment to disk. Optional: post the comment if the label is kind/question and the model cited a runbook hit. Never: close, never merge, never touch actions secrets, never follow URLs in the issue body with a browser. Issue bodies are untrusted prompts; retrieval-augmented workers may read them as questions, not as instructions.

Bind the listener on 127.0.0.1 and let nginx terminate TLS with a Let's Encrypt cert on the public name. GitHub needs a reachable HTTPS URL; your agent does not need a public Python port. Rate-limit the location in nginx so a replay of an old body still has to pass HMAC and then hits a 202/401 quickly.

GitHub event to agent worker GitHub POST issues / PR nginx TLS public 443 HMAC verify 202 then quit JSONL queue dedupe delivery worker + App token label, maybe comment never close / merge body is untrusted GitHub API short-lived token request thread does not call the model duplicates keyed by X-GitHub-Delivery App permissions: issues write, nothing else

Delivery, retries, and idempotency

GitHub retries on non-2xx and on slow handlers. If your handler calls the model, a 30-second thinking pause becomes three comments. The envelope plus a processed-ids file makes the worker idempotent: if delivery D is done, skip. Store processed ids for 14 days, same as GitHub's retry window plus slack.

When the worker comments, include a marker in the body such as "agent:triage v1" so a later event can see the bot already spoke. Do not hide the fact that a model wrote the draft. Humans should know.

nginx in front should cap the body, set client_max_body_size 64k, and not buffer for more than a couple of seconds. The Python listener is localhost; the public name is TLS only. If you must expose a health path, make it GET /healthz with no side effects, and do not put it on the same location that accepts POSTs. GitHub does not need a health check; you do, from your own probe.

Takeaway: HMAC, 202, queue, then a GitHub App worker. The webhook is plumbing; the agent is a batch job that happens to start on an event. Point GitHub at an Ubuntu 24.04 VPS in Lucknow DC01 from Netbay — TLS and the listener fit on one box, live in under 60 seconds at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance