A cron job answers one question: "run at 6am." A webhook answers a better one: "run the moment something happens." For an AI agent that triages GitHub issues, responds to Stripe payments, reacts to monitoring alerts, or replies to im-bot messages, polling is a waste of latency and tokens. This tutorial wires Hermes Agent to the outside world with webhook subscriptions — external services POST an event, and Hermes spins up an agent run on the spot, secured by sun-port, recorded in PostgreSQL, shipped in Docker, and versioned in Git.
Cron and webhooks are complementary, not competing. Cron handles scheduled work — a content pipeline that runs daily, a nightly backup, a weekly report. Webhooks handle reactive work — an event arrives, and the agent must respond within seconds.
Here is the architecture we'll build:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐
│ GitHub │──▶│ │ │ │ │ │ │ │
│ Stripe │──▶│ sun-port │──▶│ Hermes │──▶│ Agent run │──▶│ Delivery │
│ im-bot │──▶│ (auth, │ │ webhook │ │ (skills, │ │ (Telegram│
│ monitors │──▶│ TLS, RL) │ │ adapter │ │ prompt) │ │ etc.) │
└──────────┘ └──────────┘ └──────────┘ └───────────┘ └──────────┘
│
▼
┌──────────────────────────────┐
│ PostgreSQL event audit log │
└──────────────────────────────┘
The webhook platform must be enabled before you can create subscriptions. First, check its status:
$ hermes webhook list
Webhook platform is not enabled. Run `hermes gateway setup`.
Enable it with the gateway setup wizard:
$ hermes gateway setup
? Enable webhooks? Yes
? Webhook port (8644): 8644
? Global HMAC secret: (generated)
Or configure it directly in ~/.hermes/config.yaml:
platforms:
webhook:
enabled: true
extra:
host: "0.0.0.0"
port: 8644
secret: "generate-a-strong-secret-here"
Then start (or restart) the gateway and verify it's listening:
$ hermes gateway run
$ curl -s http://localhost:8644/health
{"status":"ok"}
/health endpoint is your readiness check.Each subscription maps an incoming event to an agent prompt. Hermes renders the payload into the prompt using {dot.notation} placeholders, triggers an agent run, and delivers the result to a target (Telegram, Discord, GitHub comment, or the origin).
$ hermes webhook subscribe github-issues \
--events "issues" \
--prompt "New GitHub issue #{issue.number}: {issue.title}\n\nAction: {action}\nAuthor: {issue.user.login}\nBody:\n{issue.body}\n\nPlease triage this issue and suggest a fix." \
--skills "github-issues" \
--deliver github_comment
✓ Subscription created
URL: https://agent-rule.com/webhook/github-issues
Secret: whsec_9f2a... (store this in GitHub)
In GitHub, go to Settings → Webhooks → Add webhook and set the payload URL, content type application/json, and the returned secret. GitHub will now POST to that URL on every issue event.
$ hermes webhook subscribe stripe-payments \
--events "payment_intent.succeeded,payment_intent.payment_failed" \
--prompt "Payment {data.object.status}: {data.object.amount} cents from {data.object.receipt_email}" \
--deliver telegram \
--deliver-chat-id "-100123456789"
✓ Subscription created
URL: https://agent-rule.com/webhook/stripe-payments
$ hermes webhook subscribe alerts \
--events "alert" \
--prompt "Alert: {alert.name}\nSeverity: {alert.severity}\nMessage: {alert.message}\n\nPlease investigate and suggest remediation." \
--deliver origin
✓ Subscription created
URL: https://agent-rule.com/webhook/alerts
Sometimes you don't need an agent run at all — you just want the payload pushed to a chat. The --deliver-only flag renders the prompt template and forwards it verbatim, skipping the LLM round trip entirely:
$ hermes webhook subscribe antenna-matches \
--deliver telegram \
--deliver-chat-id "123456789" \
--deliver-only \
--prompt "🎉 New match: {match.user_name} matched with you!" \
--description "Antenna match notifications"
--deliver-only returns 200 on success and 502 on target failure — so upstream services can retry intelligently. HMAC auth, rate limits, and idempotency still apply.Subscriptions persist to ~/.hermes/webhook_subscriptions.json and are hot-reloaded by the adapter. Manage them with:
$ hermes webhook list
$ hermes webhook test github-issues \
--payload '{"issue":{"number":42,"title":"Webhook test","user":{"login":"octocat"},"body":"Does this work?"},"action":"opened"}'
$ hermes webhook remove alerts
The test command is essential — it fires a synthetic payload through the full pipeline so you can verify the prompt template, the HMAC signature path, and the delivery target without waiting for a real event.
Never expose the webhook port directly. Route it through sun-port, which adds TLS termination, HMAC validation, and per-route rate limiting in front of Hermes:
# sun-port/config.yaml
server:
listen: ":443"
tls:
cert: /etc/sun-port/certs/fullchain.pem
key: /etc/sun-port/certs/privkey.pem
routes:
- match:
host: "agent-rule.com"
path: "/webhook/*"
backend:
url: "http://hermes-agent:8644"
timeout: 60s
auth:
type: hmac_sha256
secret_file: /etc/sun-port/tokens/webhook-secret.key
rate_limit:
requests_per_minute: 30
- match:
host: "agent-rule.com"
path: "/api/*"
backend:
url: "http://hermes-agent:3000"
timeout: 30s
auth:
type: bearer
token_file: /etc/sun-port/tokens/hermes-api.key
rate_limit:
requests_per_minute: 60
Verify routing after restart:
$ curl -s https://agent-rule.com/webhook/health
{"status":"ok"}
# Simulate a signed webhook (HMAC-SHA256)
$ printf '{"alert":{"name":"disk-full","severity":"critical","message":"/ is 98% full"}}' | \
openssl dgst -sha256 -hmac "$(cat /etc/sun-port/tokens/webhook-secret.key)" -binary | \
openssl base64
X3m9kQ... (use as X-Hub-Signature-256 header)
Run the Hermes gateway (and its webhook adapter) as a Docker service so it survives reboots and upgrades cleanly:
version: "3.9"
services:
sun-port:
image: sun-port:latest
container_name: sun-port
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- ./sun-port/config.yaml:/etc/sun-port/config.yaml:ro
- ./certs:/etc/sun-port/certs:ro
depends_on:
- hermes-gateway
networks:
- agent-net
hermes-gateway:
image: hermes-agent:latest
container_name: hermes-gateway
restart: unless-stopped
expose:
- "8644"
- "3000"
volumes:
- ./hermes-profiles:/root/.hermes/profiles:ro
- ./hermes-config.yaml:/root/.hermes/config.yaml:ro
- ./webhook_subscriptions.json:/root/.hermes/webhook_subscriptions.json:ro
environment:
- WEBHOOK_ENABLED=true
- WEBHOOK_PORT=8644
- WEBHOOK_SECRET_FILE=/run/secrets/webhook_secret
secrets:
- webhook_secret
networks:
- agent-net
postgres:
image: postgres:16
container_name: agent-events-db
restart: unless-stopped
environment:
POSTGRES_DB: agent_events
POSTGRES_USER: agent_rule
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- pg_password
networks:
- agent-net
networks:
agent-net:
driver: bridge
volumes:
pgdata:
secrets:
webhook_secret:
file: ./secrets/webhook_secret.txt
pg_password:
file: ./secrets/pg_password.txt
$ docker compose up -d
$ docker compose ps
NAME STATUS PORTS
sun-port Up 2 minutes 0.0.0.0:443->443/tcp, 0.0.0.0:80->80/tcp
hermes-gateway Up 2 minutes 8644/tcp, 3000/tcp
agent-events-db Up 2 minutes 5432/tcp
webhook_subscriptions.json read-only so the adapter can hot-reload subscriptions without allowing the container to overwrite your versioned config. Secrets live in /run/secrets, never in environment variables.Every webhook-triggered run should be recorded, so you can answer "what fired, when, and what did the agent do about it?" Create the schema:
-- schema.sql
CREATE TABLE IF NOT EXISTS webhook_events (
id BIGSERIAL PRIMARY KEY,
received_at TIMESTAMPTZ DEFAULT now(),
subscription TEXT NOT NULL,
source TEXT, -- 'github', 'stripe', 'im-bot', 'monitoring'
event_type TEXT,
payload JSONB, -- full incoming payload
signature_valid BOOLEAN,
run_triggered BOOLEAN DEFAULT false,
run_id TEXT, -- Hermes agent run id
delivery_target TEXT, -- 'telegram', 'github_comment', 'origin', ...
delivery_status TEXT, -- 'ok', 'failed'
latency_ms INTEGER
);
CREATE INDEX idx_events_received ON webhook_events(received_at DESC);
CREATE INDEX idx_events_subscription ON webhook_events(subscription);
CREATE INDEX idx_events_payload ON webhook_events USING GIN (payload);
-- View: failed deliveries in the last 24h
CREATE VIEW recent_webhook_failures AS
SELECT subscription, event_type, received_at, delivery_status
FROM webhook_events
WHERE delivery_status = 'failed'
AND received_at > now() - INTERVAL '24 hours'
ORDER BY received_at DESC;
Query which subscriptions fire most often — useful for spotting noisy sources that should be switched to --deliver-only:
SELECT subscription, COUNT(*) AS events,
SUM(CASE WHEN run_triggered THEN 1 ELSE 0 END) AS agent_runs,
ROUND(AVG(latency_ms)) AS avg_latency_ms
FROM webhook_events
WHERE received_at > now() - INTERVAL '7 days'
GROUP BY subscription
ORDER BY events DESC;
im-bot is a multi-agent instant-messaging platform where agents coexist in chat rooms. Connecting im-bot to Hermes webhooks closes the loop: a message in a room can trigger an agent run, and the result is delivered back into the chat.
Register an im-bot connector that POSTs message events to your webhook endpoint, then subscribe:
$ hermes webhook subscribe im-bot-mentions \
--events "message.mention" \
--prompt "im-bot message from {message.sender_name} in room {room.name}:\n\n{message.text}\n\nRespond as the room agent." \
--skills "im-bot" \
--deliver im_bot \
--deliver-chat-id "{room.id}"
✓ Subscription created
URL: https://agent-rule.com/webhook/im-bot-mentions
The flow: a user @mentions the agent in im-bot → im-bot posts a message.mention event to the webhook → Hermes runs the agent with the room's context → the reply is delivered back to the im-bot room. Because the prompt template interpolates {room.id} into the delivery target, one subscription serves every room dynamically.
message.mention webhook, you'll get an infinite loop. Add a sender filter (ignore messages from the agent's own account) or an idempotency key in the im-bot connector so self-messages never re-enter the pipeline.Subscriptions are configuration, and configuration belongs in Git. Keep webhook_subscriptions.json and sun-port/config.yaml in a repository so every change is reviewable and revertable:
$ git init agent-events && cd agent-events
$ git add webhook_subscriptions.json sun-port/config.yaml docker-compose.yml schema.sql
$ git commit -m "Add GitHub issue triage and Stripe payment webhooks"
$ git log --oneline
a1b2c3d Add GitHub issue triage and Stripe payment webhooks
Combined with a pre-commit hook that validates JSON, you guarantee the subscription file is always parseable before it ships — the same discipline covered in our CI/CD tutorial.
hermes gateway setup, then confirm curl /health returns {"status":"ok"} before creating subscriptions.{dot.notation} placeholders map payload fields into the agent prompt; test them with hermes webhook test before trusting a real event./run/secrets, never in env vars.Webhooks turn Hermes Agent from a scheduler into a reactive system. GitHub opens an issue, Stripe clears a payment, a monitor fires, a user pings you in im-bot — and the agent is already working, secured by sun-port, tracked by PostgreSQL, and shipped by Docker. That's the difference between an agent that checks and an agent that knows.