Skip to content

Commit 744749b

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 + an OPAQUE params dict forwarded verbatim to task/create; the platform does not interpret it), and 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) + the turn. Branches on the agent's ACP type: sync agents take the turn via message/send (reply returned inline); async/agentic via event/send (reply arrives on the task's stream). - 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 (GitHub/Gitea). Generic JSON normalization. - 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. - Regenerate openapi.yaml for the new route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d65011a commit 744749b

8 files changed

Lines changed: 412 additions & 0 deletions

File tree

agentex/openapi.yaml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,37 @@ paths:
364364
application/json:
365365
schema:
366366
$ref: '#/components/schemas/HTTPValidationError'
367+
/channels/webhook/{route_id}:
368+
post:
369+
tags:
370+
- Channels
371+
summary: Generic webhook channel
372+
description: 'Authenticated webhook ingress: verifies a per-route secret/HMAC
373+
and drives an agent turn. Auth is whitelisted at the middleware; the channel
374+
verifies the route secret instead.'
375+
operationId: handle_webhook_channels_webhook__route_id__post
376+
parameters:
377+
- name: route_id
378+
in: path
379+
required: true
380+
schema:
381+
type: string
382+
title: Route Id
383+
responses:
384+
'200':
385+
description: Successful Response
386+
content:
387+
application/json:
388+
schema:
389+
type: object
390+
additionalProperties: true
391+
title: Response Handle Webhook Channels Webhook Route Id Post
392+
'422':
393+
description: Validation Error
394+
content:
395+
application/json:
396+
schema:
397+
$ref: '#/components/schemas/HTTPValidationError'
367398
/tasks/{task_id}:
368399
get:
369400
tags:

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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 and an opaque
6+
`params` dict forwarded verbatim to task/create (agentex does not interpret it —
7+
the bound agent does).
8+
9+
Route bindings live in the CHANNELS_WEBHOOK_ROUTES env var (JSON) for now — the
10+
seam where a real per-config store (resolving a saved agent_config_id) plugs in later:
11+
12+
CHANNELS_WEBHOOK_ROUTES='{"demo": {"secret": "<shared-secret>",
13+
"agent_name": "golden-agent",
14+
"params": {"system_prompt": "You are ...", "mcps": []}}}'
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import os
21+
22+
from fastapi import APIRouter, HTTPException, Request
23+
24+
from src.domain.channels.base import ChannelBinding
25+
from src.domain.channels.router import ChannelRouter
26+
from src.domain.channels.webhook import MAX_BODY_BYTES, WebhookChannel
27+
from src.domain.use_cases.agents_acp_use_case import DAgentsACPUseCase
28+
from src.domain.use_cases.agents_use_case import DAgentsUseCase
29+
from src.utils.logging import make_logger
30+
31+
logger = make_logger(__name__)
32+
33+
router = APIRouter(prefix="/channels", tags=["Channels"])
34+
35+
# Channel registry — add "slack": SlackChannel() here when it lands.
36+
_WEBHOOK = WebhookChannel()
37+
38+
39+
def _webhook_binding(route_id: str) -> ChannelBinding | None:
40+
"""Resolve a webhook route's binding. Env-backed seam; replace with a config store."""
41+
raw = os.environ.get("CHANNELS_WEBHOOK_ROUTES")
42+
if not raw:
43+
return None
44+
try:
45+
routes = json.loads(raw)
46+
except json.JSONDecodeError:
47+
logger.error("[channels] CHANNELS_WEBHOOK_ROUTES is not valid JSON")
48+
return None
49+
cfg = routes.get(route_id)
50+
if not isinstance(cfg, dict) or not cfg.get("secret") or not cfg.get("agent_name"):
51+
return None
52+
# `params` is opaque — forwarded verbatim to task/create; agentex does not
53+
# interpret it (an agent like golden-agent reads system_prompt/mcps/etc. there).
54+
params = cfg.get("params")
55+
return ChannelBinding(
56+
secret=cfg["secret"],
57+
agent_name=cfg["agent_name"],
58+
params=params if isinstance(params, dict) else {},
59+
)
60+
61+
62+
@router.post(
63+
"/webhook/{route_id}",
64+
summary="Generic webhook channel",
65+
description="Authenticated webhook ingress: verifies a per-route secret/HMAC and "
66+
"drives an agent turn. Auth is whitelisted at the middleware; the channel verifies "
67+
"the route secret instead.",
68+
)
69+
async def handle_webhook(
70+
route_id: str,
71+
request: Request,
72+
agents_acp_use_case: DAgentsACPUseCase,
73+
agents_use_case: DAgentsUseCase,
74+
) -> dict:
75+
binding = _webhook_binding(route_id)
76+
if binding is None:
77+
raise HTTPException(status_code=404, detail="unknown route")
78+
79+
raw = await request.body()
80+
if len(raw) > MAX_BODY_BYTES:
81+
raise HTTPException(status_code=413, detail="payload too large")
82+
if not _WEBHOOK.authenticate(binding, request, raw):
83+
raise HTTPException(status_code=401, detail="unauthorized")
84+
if "application/json" not in request.headers.get("content-type", ""):
85+
raise HTTPException(status_code=400, detail="expected application/json")
86+
try:
87+
body = json.loads(raw)
88+
except json.JSONDecodeError:
89+
raise HTTPException(status_code=400, detail="invalid json")
90+
if not isinstance(body, dict):
91+
raise HTTPException(status_code=400, detail="json body must be an object")
92+
93+
# Resolve the agent's ACP type so the router picks the right turn method
94+
# (sync -> message/send returns the reply inline; async -> event/send).
95+
agent = await agents_use_case.get(name=binding.agent_name)
96+
97+
inbound = _WEBHOOK.to_inbound(route_id, body)
98+
result = await ChannelRouter(agents_acp_use_case).dispatch(
99+
inbound, binding, agent.acp_type
100+
)
101+
response = {
102+
"ok": True,
103+
"channel": "webhook",
104+
"route_id": route_id,
105+
"task_id": result.task_id,
106+
}
107+
if result.reply is not None:
108+
response["reply"] = result.reply
109+
return response
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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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(
37+
f"{agent_name}:{self.channel}:{basis}".encode()
38+
).hexdigest()[:16]
39+
return f"ch-{self.channel}-{digest}"
40+
41+
42+
@dataclass
43+
class ChannelBinding:
44+
"""A route's binding to one agent.
45+
46+
`params` is an OPAQUE dict forwarded verbatim as the task/create params — the
47+
agentex platform does not interpret it. Whatever a given agent expects there
48+
(e.g. golden-agent's system_prompt / mcps / harness / model) is that agent's
49+
concern, not the channel layer's. Later this can be sourced from a saved config.
50+
"""
51+
52+
secret: str
53+
agent_name: str
54+
params: dict[str, Any] = field(default_factory=dict)
55+
# Headers the router forwards to the agent (auth/delegation). Empty for local/open.
56+
forward_headers: dict[str, str] = field(default_factory=dict)
57+
58+
59+
def verify_shared_secret(presented: str | None, secret: str) -> bool:
60+
"""Timing-safe shared-secret check (OpenClaw `safeEqualSecret`)."""
61+
if not presented or not secret:
62+
return False
63+
return hmac.compare_digest(presented, secret)
64+
65+
66+
def verify_hmac_sha256(secret: str, body: bytes, signature_header: str | None) -> bool:
67+
"""Verify an `sha256=<hex>` HMAC signature (GitHub/Gitea-style)."""
68+
if not signature_header or not signature_header.startswith("sha256="):
69+
return False
70+
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
71+
return hmac.compare_digest(expected, signature_header)
72+
73+
74+
class Channel(ABC):
75+
"""One external surface. Ingress = authenticate + to_inbound. Outbound is
76+
channel-specific (webhook replies in the HTTP response; Slack posts a message)."""
77+
78+
name: str = "unknown"
79+
80+
@abstractmethod
81+
def authenticate(
82+
self, binding: ChannelBinding, request: Request, raw_body: bytes
83+
) -> bool:
84+
"""Return True iff the request is authentic for this binding."""
85+
86+
@abstractmethod
87+
def to_inbound(self, route_id: str, body: dict[str, Any]) -> InboundMessage:
88+
"""Normalize the parsed JSON payload into an InboundMessage."""
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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+
Works for both ACP types: sync agents take the turn via message/send (reply returned
5+
inline); async/agentic agents via event/send (fire-and-forget, reply arrives on the
6+
task's message stream). Continuity is free: the InboundMessage's session_key is reused
7+
as the agentex task name (task/create is get-or-create on name).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
14+
from src.domain.channels.base import ChannelBinding, InboundMessage
15+
from src.domain.entities.agents import ACPType
16+
from src.domain.entities.agents_rpc import (
17+
AgentRPCMethod,
18+
CreateTaskRequestEntity,
19+
SendEventRequestEntity,
20+
SendMessageRequestEntity,
21+
)
22+
from src.domain.entities.task_messages import (
23+
MessageAuthor,
24+
TaskMessageContentType,
25+
TextContentEntity,
26+
)
27+
from src.domain.use_cases.agents_acp_use_case import AgentsACPUseCase
28+
from src.utils.logging import make_logger
29+
30+
logger = make_logger(__name__)
31+
32+
33+
@dataclass
34+
class DispatchResult:
35+
task_id: str
36+
# Sync agents return their reply inline; async agents reply later on the task
37+
# stream, so reply is None and the caller can poll /messages if it wants it.
38+
reply: str | None = None
39+
40+
41+
class ChannelRouter:
42+
def __init__(self, acp_use_case: AgentsACPUseCase):
43+
self._acp = acp_use_case
44+
45+
async def dispatch(
46+
self, inbound: InboundMessage, binding: ChannelBinding, acp_type: ACPType
47+
) -> DispatchResult:
48+
session_key = inbound.session_key(binding.agent_name)
49+
headers = binding.forward_headers or None
50+
51+
task = await self._acp.handle_rpc_request(
52+
method=AgentRPCMethod.TASK_CREATE,
53+
params=CreateTaskRequestEntity(
54+
name=session_key,
55+
params=binding.params,
56+
task_metadata={
57+
"channel": inbound.channel,
58+
"route_id": inbound.route_id,
59+
"peer_id": inbound.peer_id,
60+
"sender_id": inbound.sender_id,
61+
},
62+
),
63+
agent_name=binding.agent_name,
64+
request_headers=headers,
65+
)
66+
task_id = task.id
67+
logger.info(
68+
"[channels] %s route=%s acp=%s -> task %s (session %s)",
69+
inbound.channel,
70+
inbound.route_id,
71+
acp_type.value,
72+
task_id,
73+
session_key,
74+
)
75+
76+
content = TextContentEntity(author=MessageAuthor.USER, content=inbound.text)
77+
78+
if acp_type == ACPType.SYNC:
79+
# Sync: message/send carries the turn and returns the reply messages inline.
80+
messages = await self._acp.handle_rpc_request(
81+
method=AgentRPCMethod.MESSAGE_SEND,
82+
params=SendMessageRequestEntity(
83+
task_id=task_id, content=content, stream=False
84+
),
85+
agent_name=binding.agent_name,
86+
request_headers=headers,
87+
)
88+
return DispatchResult(task_id=task_id, reply=_agent_text(messages))
89+
90+
# Async / agentic: event/send delivers the turn; the reply lands on the stream.
91+
await self._acp.handle_rpc_request(
92+
method=AgentRPCMethod.EVENT_SEND,
93+
params=SendEventRequestEntity(task_id=task_id, content=content),
94+
agent_name=binding.agent_name,
95+
request_headers=headers,
96+
)
97+
return DispatchResult(task_id=task_id, reply=None)
98+
99+
100+
def _agent_text(messages: object) -> str | None:
101+
"""Join the agent-authored text from a sync message/send result."""
102+
if not isinstance(messages, list):
103+
return None
104+
parts: list[str] = []
105+
for m in messages:
106+
content = getattr(m, "content", None)
107+
if (
108+
content is not None
109+
and getattr(content, "type", None) == TaskMessageContentType.TEXT
110+
and getattr(content, "author", None) == MessageAuthor.AGENT
111+
):
112+
text = getattr(content, "content", "") or ""
113+
if text.strip():
114+
parts.append(text.strip())
115+
return "\n\n".join(parts) if parts else None

0 commit comments

Comments
 (0)