feat(webhook): direct delivery mode for zero-LLM push notifications (#12473)

External services can now push plain-text notifications to a user's chat
via the webhook adapter without invoking the agent. Set deliver_only=true
on a route and the rendered prompt template becomes the literal message
body — dispatched directly to the configured target (Telegram, Discord,
Slack, GitHub PR comment, etc.).

Reuses all existing webhook infrastructure: HMAC-SHA256 signature
validation, per-route rate limiting, idempotency cache, body-size limits,
template rendering with dot-notation, home-channel fallback. No new HTTP
server, no new auth scheme, no new port.

Use cases: Supabase/Firebase webhooks → user notifications, monitoring
alert forwarding, inter-agent pings, background job completion alerts.

Changes:
- gateway/platforms/webhook.py: new _direct_deliver() helper + early
  dispatch branch in _handle_webhook when deliver_only=true. Startup
  validation rejects deliver_only with deliver=log.
- hermes_cli/main.py + hermes_cli/webhook.go: --deliver-only flag on
  subscribe; list/show output marks direct-delivery routes.
- website/docs/user-guide/messaging/webhooks.md: new Direct Delivery
  Mode section with config example, CLI example, response codes.
- skills/devops/webhook-subscriptions/SKILL.md: document --deliver-only
  with use cases (bumped to v1.1.0).
- tests/gateway/test_webhook_deliver_only.py: 14 new tests covering
  agent bypass, template rendering, status codes, HMAC still enforced,
  idempotency still applies, rate limit still applies, startup
  validation, and direct-deliver dispatch.

Validation: 78 webhook tests pass (64 existing + 14 new). E2E verified
with real aiohttp server + real urllib POST — agent not invoked, target
adapter.send() called with rendered template, duplicate delivery_id
suppressed.

Closes the gap identified in PR #12117 (thanks to @H1an1 / Antenna team)
without adding a second HTTP ingress server.
This commit is contained in:
Teknium
2026-04-19 05:18:19 -07:00
committed by GitHub
parent 66ee081dc1
commit 206a449b29
6 changed files with 699 additions and 4 deletions

View File

@@ -7002,6 +7002,13 @@ For more help on a command:
wh_sub.add_argument(
"--secret", default="", help="HMAC secret (auto-generated if omitted)"
)
wh_sub.add_argument(
"--deliver-only",
action="store_true",
help="Skip the agent — deliver the rendered prompt directly as the "
"message. Zero LLM cost. Requires --deliver to be a real target "
"(not 'log').",
)
webhook_subparsers.add_parser(
"list", aliases=["ls"], help="List all dynamic subscriptions"

View File

@@ -155,6 +155,15 @@ def _cmd_subscribe(args):
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if getattr(args, "deliver_only", False):
if route["deliver"] == "log":
print(
"Error: --deliver-only requires --deliver to be a real target "
"(telegram, discord, slack, github_comment, etc.) — not 'log'."
)
return
route["deliver_only"] = True
if args.deliver_chat_id:
route["deliver_extra"] = {"chat_id": args.deliver_chat_id}
@@ -172,9 +181,12 @@ def _cmd_subscribe(args):
else:
print(" Events: (all)")
print(f" Deliver: {route['deliver']}")
if route.get("deliver_only"):
print(" Mode: direct delivery (no agent, zero LLM cost)")
if route.get("prompt"):
prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "")
print(f" Prompt: {prompt_preview}")
label = "Message" if route.get("deliver_only") else "Prompt"
print(f" {label}: {prompt_preview}")
print(f"\n Configure your service to POST to the URL above.")
print(f" Use the secret for HMAC-SHA256 signature validation.")
print(f" The gateway must be running to receive events (hermes gateway run).\n")
@@ -192,6 +204,8 @@ def _cmd_list(args):
for name, route in subs.items():
events = ", ".join(route.get("events", [])) or "(all)"
deliver = route.get("deliver", "log")
if route.get("deliver_only"):
deliver = f"{deliver} (direct — no agent)"
desc = route.get("description", "")
print(f"{name}")
if desc: