Skip to content

Commit 2e41d47

Browse files
danielmillerpclaude
andcommitted
feat(channels): add extensible channel abstraction with generic webhook ingress
Introduce a channel layer that normalizes external surfaces into agent turns, so platforms beyond the chat UI can drive agents. The agent-driving core is channel-agnostic; a new channel (e.g. Slack) implements one interface without touching the router. - domain/channels/base.py: InboundMessage, Channel ABC (authenticate + to_inbound), ChannelBinding (route -> agent + inline params), timing-safe shared-secret and HMAC-SHA256 verification helpers. - domain/channels/router.py: ChannelRouter.dispatch() — InboundMessage -> task/create (get-or-create on a per-conversation session key) + event/send via the in-process ACP use case. - domain/channels/webhook.py: WebhookChannel — generic HTTP ingress with a per-route secret. Accepts Authorization: Bearer / x-openclaw-webhook-secret, or an X-Hub-Signature-256 HMAC. Generic JSON normalization (no source-specific shaping). - api/routes/channels.py: POST /channels/webhook/{route_id}. Route bindings load from the CHANNELS_WEBHOOK_ROUTES env var for now (seam for a future config store). - Whitelist /channels/webhook in the auth middleware so it bypasses the agent API-key check and verifies its own per-route secret instead; register the router. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d65011a commit 2e41d47

7 files changed

Lines changed: 317 additions & 0 deletions

File tree

agentex/src/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
agent_api_keys,
3232
agent_task_tracker,
3333
agents,
34+
channels,
3435
checkpoints,
3536
deployment_history,
3637
deployments,
@@ -193,6 +194,7 @@ async def handle_unexpected(request, exc):
193194

194195
# Include all routers
195196
fastapi_app.include_router(agents.router)
197+
fastapi_app.include_router(channels.router)
196198
fastapi_app.include_router(tasks.router)
197199
fastapi_app.include_router(messages.router)
198200
fastapi_app.include_router(spans.router)

agentex/src/api/middleware_utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"/readyz",
2929
"/ping",
3030
"/echo",
31+
# Channels (webhook, …) bypass agentex API-key auth and verify a per-route
32+
# shared secret / HMAC inside the channel instead.
33+
"/channels/webhook",
3134
}
3235

3336
DROP_HEADERS: set[str] = {

agentex/src/api/routes/channels.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Channels API: external surfaces (webhook now, Slack later) that drive agent turns.
2+
3+
The webhook endpoint is auth-whitelisted in `middleware_utils.WHITELISTED_ROUTES`
4+
(it bypasses the agentex API-key auth) and instead verifies a per-route shared
5+
secret / HMAC inside the channel. Each route binds to one agent + inline config.
6+
7+
Route bindings live in the CHANNELS_WEBHOOK_ROUTES env var (JSON) for now — the
8+
seam where a real per-config store (resolving a saved agent_config_id) plugs in later:
9+
10+
CHANNELS_WEBHOOK_ROUTES='{"demo": {"secret": "<shared-secret>",
11+
"agent_name": "golden-agent", "system_prompt": "You are ...",
12+
"harness": "claude-code", "model": "..."}}'
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import os
19+
20+
from fastapi import APIRouter, HTTPException, Request
21+
22+
from src.domain.channels.base import ChannelBinding
23+
from src.domain.channels.router import ChannelRouter
24+
from src.domain.channels.webhook import MAX_BODY_BYTES, WebhookChannel
25+
from src.domain.use_cases.agents_acp_use_case import DAgentsACPUseCase
26+
from src.utils.logging import make_logger
27+
28+
logger = make_logger(__name__)
29+
30+
router = APIRouter(prefix="/channels", tags=["Channels"])
31+
32+
# Channel registry — add "slack": SlackChannel() here when it lands.
33+
_WEBHOOK = WebhookChannel()
34+
35+
36+
def _webhook_binding(route_id: str) -> ChannelBinding | None:
37+
"""Resolve a webhook route's binding. Env-backed seam; replace with a config store."""
38+
raw = os.environ.get("CHANNELS_WEBHOOK_ROUTES")
39+
if not raw:
40+
return None
41+
try:
42+
routes = json.loads(raw)
43+
except json.JSONDecodeError:
44+
logger.error("[channels] CHANNELS_WEBHOOK_ROUTES is not valid JSON")
45+
return None
46+
cfg = routes.get(route_id)
47+
if not isinstance(cfg, dict) or not cfg.get("secret") or not cfg.get("agent_name"):
48+
return None
49+
return ChannelBinding(
50+
secret=cfg["secret"],
51+
agent_name=cfg["agent_name"],
52+
system_prompt=cfg.get("system_prompt"),
53+
harness=cfg.get("harness", "claude-code"),
54+
model=cfg.get("model"),
55+
allowed_tools=cfg.get("allowed_tools", []),
56+
mcps=cfg.get("mcps", []),
57+
)
58+
59+
60+
@router.post(
61+
"/webhook/{route_id}",
62+
summary="Generic webhook channel",
63+
description="Authenticated webhook ingress: verifies a per-route secret/HMAC and "
64+
"drives an agent turn. Auth is whitelisted at the middleware; the channel verifies "
65+
"the route secret instead.",
66+
)
67+
async def handle_webhook(
68+
route_id: str,
69+
request: Request,
70+
agents_acp_use_case: DAgentsACPUseCase,
71+
) -> dict:
72+
binding = _webhook_binding(route_id)
73+
if binding is None:
74+
raise HTTPException(status_code=404, detail="unknown route")
75+
76+
raw = await request.body()
77+
if len(raw) > MAX_BODY_BYTES:
78+
raise HTTPException(status_code=413, detail="payload too large")
79+
if not _WEBHOOK.authenticate(binding, request, raw):
80+
raise HTTPException(status_code=401, detail="unauthorized")
81+
if "application/json" not in request.headers.get("content-type", ""):
82+
raise HTTPException(status_code=400, detail="expected application/json")
83+
try:
84+
body = json.loads(raw)
85+
except json.JSONDecodeError:
86+
raise HTTPException(status_code=400, detail="invalid json")
87+
if not isinstance(body, dict):
88+
raise HTTPException(status_code=400, detail="json body must be an object")
89+
90+
inbound = _WEBHOOK.to_inbound(route_id, body)
91+
task_id = await ChannelRouter(agents_acp_use_case).dispatch(inbound, binding)
92+
return {"ok": True, "channel": "webhook", "route_id": route_id, "task_id": task_id}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Channels: normalize external surfaces (webhook, Slack, …) into agent turns."""
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Channel abstraction: normalize any external surface (webhook, Slack, …) into one
2+
inbound shape, and drive an agent turn from it.
3+
4+
Modeled on OpenClaw's channel plugins (github.com/openclaw/openclaw,
5+
`src/channels/**`) and the claw0 tutorial: every platform produces the same
6+
`InboundMessage`; the agent-driving core is channel-agnostic. A new channel
7+
(Slack, etc.) implements `Channel` — `authenticate` + `to_inbound` for ingress,
8+
and (when it needs to push replies) its own outbound — without touching the router.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import hashlib
14+
import hmac
15+
from abc import ABC, abstractmethod
16+
from dataclasses import dataclass, field
17+
from typing import Any
18+
19+
from starlette.requests import Request
20+
21+
22+
@dataclass
23+
class InboundMessage:
24+
"""Normalized inbound event. Every channel produces this; the router only sees this."""
25+
26+
text: str
27+
channel: str # "webhook" | "slack" | …
28+
route_id: str = "" # which binding received it (webhook route / slack team)
29+
peer_id: str = "" # conversation scope: DM user, channel:thread, route_id, …
30+
sender_id: str = ""
31+
raw: dict[str, Any] = field(default_factory=dict)
32+
33+
def session_key(self, agent_name: str) -> str:
34+
"""Stable per-conversation key → reused as the agentex task name (get-or-create)."""
35+
basis = self.peer_id or self.route_id or "main"
36+
digest = hashlib.sha1(f"{agent_name}:{self.channel}:{basis}".encode()).hexdigest()[:16]
37+
return f"ch-{self.channel}-{digest}"
38+
39+
40+
@dataclass
41+
class ChannelBinding:
42+
"""A route's binding to one agent config. The 'configs later' seam — for now the
43+
system prompt is inlined; later this resolves a saved agent_config_id."""
44+
45+
secret: str
46+
agent_name: str
47+
system_prompt: str | None = None
48+
harness: str = "claude-code"
49+
model: str | None = None
50+
allowed_tools: list[str] = field(default_factory=list)
51+
mcps: list[str] = field(default_factory=list)
52+
# Headers the router forwards to the agent (auth/delegation). Empty for local/open.
53+
forward_headers: dict[str, str] = field(default_factory=dict)
54+
55+
def task_params(self) -> dict[str, Any]:
56+
params: dict[str, Any] = {"harness": self.harness}
57+
if self.system_prompt:
58+
params["system_prompt"] = self.system_prompt
59+
if self.model:
60+
params["model"] = self.model
61+
if self.allowed_tools:
62+
params["allowed_tools"] = self.allowed_tools
63+
if self.mcps:
64+
params["mcps"] = self.mcps
65+
return params
66+
67+
68+
def verify_shared_secret(presented: str | None, secret: str) -> bool:
69+
"""Timing-safe shared-secret check (OpenClaw `safeEqualSecret`)."""
70+
if not presented or not secret:
71+
return False
72+
return hmac.compare_digest(presented, secret)
73+
74+
75+
def verify_hmac_sha256(secret: str, body: bytes, signature_header: str | None) -> bool:
76+
"""Verify an `sha256=<hex>` HMAC signature (GitHub/Gitea-style)."""
77+
if not signature_header or not signature_header.startswith("sha256="):
78+
return False
79+
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
80+
return hmac.compare_digest(expected, signature_header)
81+
82+
83+
class Channel(ABC):
84+
"""One external surface. Ingress = authenticate + to_inbound. Outbound is
85+
channel-specific (webhook replies in the HTTP response; Slack posts a message)."""
86+
87+
name: str = "unknown"
88+
89+
@abstractmethod
90+
def authenticate(self, binding: ChannelBinding, request: Request, raw_body: bytes) -> bool:
91+
"""Return True iff the request is authentic for this binding."""
92+
93+
@abstractmethod
94+
def to_inbound(self, route_id: str, body: dict[str, Any]) -> InboundMessage:
95+
"""Normalize the parsed JSON payload into an InboundMessage."""
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""ChannelRouter: turn a normalized InboundMessage into an agent turn.
2+
3+
Channel-agnostic — the same path serves webhook, Slack, or any future channel.
4+
Continuity is free: the InboundMessage's session_key is reused as the agentex task
5+
name (task/create is get-or-create on name), so repeated events on the same
6+
conversation continue the same task.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from src.domain.channels.base import ChannelBinding, InboundMessage
12+
from src.domain.entities.agents_rpc import (
13+
AgentRPCMethod,
14+
CreateTaskRequestEntity,
15+
SendEventRequestEntity,
16+
)
17+
from src.domain.entities.task_messages import MessageAuthor, TextContentEntity
18+
from src.domain.use_cases.agents_acp_use_case import AgentsACPUseCase
19+
from src.utils.logging import make_logger
20+
21+
logger = make_logger(__name__)
22+
23+
24+
class ChannelRouter:
25+
def __init__(self, acp_use_case: AgentsACPUseCase):
26+
self._acp = acp_use_case
27+
28+
async def dispatch(self, inbound: InboundMessage, binding: ChannelBinding) -> str:
29+
"""Get-or-create the conversation's task, then deliver the inbound text as an
30+
event. Returns the task_id. Reply delivery is the channel's concern (sync HTTP
31+
response, Slack post, …) and/or retrieved via the task's message stream."""
32+
session_key = inbound.session_key(binding.agent_name)
33+
headers = binding.forward_headers or None
34+
35+
task = await self._acp.handle_rpc_request(
36+
method=AgentRPCMethod.TASK_CREATE,
37+
params=CreateTaskRequestEntity(
38+
name=session_key,
39+
params=binding.task_params(),
40+
task_metadata={
41+
"channel": inbound.channel,
42+
"route_id": inbound.route_id,
43+
"peer_id": inbound.peer_id,
44+
"sender_id": inbound.sender_id,
45+
},
46+
),
47+
agent_name=binding.agent_name,
48+
request_headers=headers,
49+
)
50+
task_id = task.id
51+
logger.info(
52+
"[channels] %s route=%s -> task %s (session %s)",
53+
inbound.channel, inbound.route_id, task_id, session_key,
54+
)
55+
56+
await self._acp.handle_rpc_request(
57+
method=AgentRPCMethod.EVENT_SEND,
58+
params=SendEventRequestEntity(
59+
task_id=task_id,
60+
content=TextContentEntity(author=MessageAuthor.USER, content=inbound.text),
61+
),
62+
agent_name=binding.agent_name,
63+
request_headers=headers,
64+
)
65+
return task_id
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""WebhookChannel: generic HTTP ingress (GitHub/Gitea/Zapier/anything that POSTs JSON).
2+
3+
Modeled on OpenClaw `extensions/webhooks`: per-route shared secret, POST + JSON only,
4+
size cap, timing-safe auth. Accepts either:
5+
- `Authorization: Bearer <secret>` / `x-openclaw-webhook-secret: <secret>` (generic), or
6+
- `X-Hub-Signature-256: sha256=<hmac>` (GitHub/Gitea)
7+
8+
The payload is normalized generically (text/message/goal/prompt, else the raw JSON).
9+
Source-specific shaping (e.g. PR rendering) belongs to the caller or the agent's
10+
system prompt — this stays a generic channel.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from typing import Any
17+
18+
from starlette.requests import Request
19+
20+
from src.domain.channels.base import (
21+
Channel,
22+
ChannelBinding,
23+
InboundMessage,
24+
verify_hmac_sha256,
25+
verify_shared_secret,
26+
)
27+
28+
MAX_BODY_BYTES = 256 * 1024
29+
30+
31+
class WebhookChannel(Channel):
32+
name = "webhook"
33+
34+
def authenticate(self, binding: ChannelBinding, request: Request, raw_body: bytes) -> bool:
35+
gh_sig = request.headers.get("x-hub-signature-256")
36+
if gh_sig is not None:
37+
return verify_hmac_sha256(binding.secret, raw_body, gh_sig)
38+
auth = request.headers.get("authorization", "")
39+
presented = auth[len("Bearer "):].strip() if auth.startswith("Bearer ") else None
40+
presented = presented or request.headers.get("x-openclaw-webhook-secret")
41+
return verify_shared_secret(presented, binding.secret)
42+
43+
def to_inbound(self, route_id: str, body: dict[str, Any]) -> InboundMessage:
44+
return InboundMessage(
45+
text=_render_text(body),
46+
channel=self.name,
47+
route_id=route_id,
48+
peer_id=route_id,
49+
sender_id="webhook",
50+
raw=body,
51+
)
52+
53+
54+
def _render_text(body: dict[str, Any]) -> str:
55+
for key in ("text", "message", "goal", "prompt"):
56+
value = body.get(key)
57+
if isinstance(value, str) and value.strip():
58+
return value.strip()
59+
return json.dumps(body, indent=2)[:8000]

0 commit comments

Comments
 (0)