Skip to content

Commit 239fb1a

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 and, for channels that respond, delivers the agent's reply back. 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, ChannelBinding (route -> agent + an OPAQUE params dict forwarded verbatim to task/create; the platform does not interpret it), timing-safe shared-secret / HMAC-SHA256 verifiers, and the Channel ABC. Ingress: authenticate + to_inbound. Outbound (optional, OpenClaw-style): deliver + chunk (textChunkLimit), plus deliver_reply() — the buffered dispatcher that chunks a reply and delivers each block. A plain webhook leaves outbound unset (its reply is the HTTP response); push channels (Slack) set supports_outbound and implement deliver. - domain/channels/router.py: ChannelRouter.dispatch() — task/create (get-or-create on a per-conversation session key) + the turn, branching on ACP type (sync: message/send returns the reply inline; async: event/send, reply lands on the stream). await_reply() retrieves an async agent's settled reply for responding channels. - 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} (+ optional ?wait to return the reply for synchronous callers). Route bindings load from CHANNELS_WEBHOOK_ROUTES env 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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d65011a commit 239fb1a

8 files changed

Lines changed: 537 additions & 0 deletions

File tree

agentex/openapi.yaml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,48 @@ 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+
- name: wait
384+
in: query
385+
required: false
386+
schema:
387+
type: boolean
388+
description: 'If true, wait for the agent''s reply and return it (for synchronous
389+
callers). Default false: return immediately with the task_id.'
390+
default: false
391+
title: Wait
392+
description: 'If true, wait for the agent''s reply and return it (for synchronous
393+
callers). Default false: return immediately with the task_id.'
394+
responses:
395+
'200':
396+
description: Successful Response
397+
content:
398+
application/json:
399+
schema:
400+
type: object
401+
additionalProperties: true
402+
title: Response Handle Webhook Channels Webhook Route Id Post
403+
'422':
404+
description: Validation Error
405+
content:
406+
application/json:
407+
schema:
408+
$ref: '#/components/schemas/HTTPValidationError'
367409
/tasks/{task_id}:
368410
get:
369411
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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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, Query, 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.services.task_message_service import DTaskMessageService
28+
from src.domain.use_cases.agents_acp_use_case import DAgentsACPUseCase
29+
from src.domain.use_cases.agents_use_case import DAgentsUseCase
30+
from src.utils.logging import make_logger
31+
32+
logger = make_logger(__name__)
33+
34+
router = APIRouter(prefix="/channels", tags=["Channels"])
35+
36+
# Channel registry — add "slack": SlackChannel() here when it lands.
37+
_WEBHOOK = WebhookChannel()
38+
39+
40+
def _webhook_binding(route_id: str) -> ChannelBinding | None:
41+
"""Resolve a webhook route's binding. Env-backed seam; replace with a config store."""
42+
raw = os.environ.get("CHANNELS_WEBHOOK_ROUTES")
43+
if not raw:
44+
return None
45+
try:
46+
routes = json.loads(raw)
47+
except json.JSONDecodeError:
48+
logger.error("[channels] CHANNELS_WEBHOOK_ROUTES is not valid JSON")
49+
return None
50+
cfg = routes.get(route_id)
51+
if not isinstance(cfg, dict) or not cfg.get("secret") or not cfg.get("agent_name"):
52+
return None
53+
# `params` is opaque — forwarded verbatim to task/create; agentex does not
54+
# interpret it (an agent like golden-agent reads system_prompt/mcps/etc. there).
55+
params = cfg.get("params")
56+
return ChannelBinding(
57+
secret=cfg["secret"],
58+
agent_name=cfg["agent_name"],
59+
params=params if isinstance(params, dict) else {},
60+
)
61+
62+
63+
@router.post(
64+
"/webhook/{route_id}",
65+
summary="Generic webhook channel",
66+
description="Authenticated webhook ingress: verifies a per-route secret/HMAC and "
67+
"drives an agent turn. Auth is whitelisted at the middleware; the channel verifies "
68+
"the route secret instead.",
69+
)
70+
async def handle_webhook(
71+
route_id: str,
72+
request: Request,
73+
agents_acp_use_case: DAgentsACPUseCase,
74+
agents_use_case: DAgentsUseCase,
75+
task_message_service: DTaskMessageService,
76+
wait: bool = Query(
77+
False,
78+
description="If true, wait for the agent's reply and return it (for synchronous "
79+
"callers). Default false: return immediately with the task_id.",
80+
),
81+
) -> dict:
82+
binding = _webhook_binding(route_id)
83+
if binding is None:
84+
raise HTTPException(status_code=404, detail="unknown route")
85+
86+
raw = await request.body()
87+
if len(raw) > MAX_BODY_BYTES:
88+
raise HTTPException(status_code=413, detail="payload too large")
89+
if not _WEBHOOK.authenticate(binding, request, raw):
90+
raise HTTPException(status_code=401, detail="unauthorized")
91+
if "application/json" not in request.headers.get("content-type", ""):
92+
raise HTTPException(status_code=400, detail="expected application/json")
93+
try:
94+
body = json.loads(raw)
95+
except json.JSONDecodeError:
96+
raise HTTPException(status_code=400, detail="invalid json")
97+
if not isinstance(body, dict):
98+
raise HTTPException(status_code=400, detail="json body must be an object")
99+
100+
# Resolve the agent's ACP type so the router picks the right turn method
101+
# (sync -> message/send returns the reply inline; async -> event/send).
102+
agent = await agents_use_case.get(name=binding.agent_name)
103+
104+
inbound = _WEBHOOK.to_inbound(route_id, body)
105+
router_ = ChannelRouter(agents_acp_use_case, task_message_service)
106+
result = await router_.dispatch(inbound, binding, agent.acp_type)
107+
108+
# A plain webhook has no outbound push (supports_outbound is False) — its reply is
109+
# the HTTP response. Sync agents reply inline; for async, `wait` streams it back.
110+
# A push channel (Slack) would instead: reply = result.reply or await_reply(...);
111+
# await deliver_reply(channel, peer_id, reply).
112+
reply = result.reply
113+
if reply is None and wait:
114+
reply = await router_.await_reply(result.task_id, result.after_id)
115+
116+
response = {
117+
"ok": True,
118+
"channel": "webhook",
119+
"route_id": route_id,
120+
"task_id": result.task_id,
121+
}
122+
if reply is not None:
123+
response["reply"] = reply
124+
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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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, modeled on OpenClaw's channel plugin facets.
76+
77+
Ingress (required): `authenticate` + `to_inbound` (normalize an inbound event).
78+
Outbound (optional): `deliver` + `chunk` — OpenClaw's `ChannelOutboundAdapter`
79+
(`deliver` + `chunker`/`textChunkLimit`). Interactive channels that push replies
80+
(Slack -> chat.postMessage) set `supports_outbound = True` and implement `deliver`.
81+
A plain webhook leaves it unset — its reply is the HTTP response, so the caller
82+
returns the reply rather than pushing it. `deliver_reply()` (below) is OpenClaw's
83+
buffered dispatcher: it chunks the agent reply and calls `deliver` per block.
84+
"""
85+
86+
name: str = "unknown"
87+
supports_outbound: bool = False
88+
# Platform message size limit for chunking (OpenClaw textChunkLimit). None = no split.
89+
text_chunk_limit: int | None = None
90+
91+
@abstractmethod
92+
def authenticate(
93+
self, binding: ChannelBinding, request: Request, raw_body: bytes
94+
) -> bool:
95+
"""Return True iff the request is authentic for this binding."""
96+
97+
@abstractmethod
98+
def to_inbound(self, route_id: str, body: dict[str, Any]) -> InboundMessage:
99+
"""Normalize the parsed JSON payload into an InboundMessage."""
100+
101+
async def deliver(self, peer_id: str, text: str) -> None:
102+
"""Send one (already-chunked) reply block to conversation `peer_id`.
103+
104+
Default: unsupported. Push channels override this (and set supports_outbound).
105+
"""
106+
raise NotImplementedError(f"channel {self.name!r} has no outbound deliver()")
107+
108+
def chunk(self, text: str) -> list[str]:
109+
"""Split a reply to `text_chunk_limit`, preferring paragraph boundaries
110+
(OpenClaw's chunker, markdown-agnostic default)."""
111+
limit = self.text_chunk_limit
112+
if not limit or len(text) <= limit:
113+
return [text]
114+
out: list[str] = []
115+
cur = ""
116+
for para in text.split("\n\n"):
117+
if cur and len(cur) + len(para) + 2 > limit:
118+
out.append(cur)
119+
cur = ""
120+
cur = f"{cur}\n\n{para}" if cur else para
121+
if cur:
122+
out.append(cur)
123+
return out or [text[:limit]]
124+
125+
126+
async def deliver_reply(channel: Channel, peer_id: str, text: str) -> None:
127+
"""Buffered reply dispatcher (OpenClaw dispatchReplyWithBufferedBlockDispatcher):
128+
chunk the agent reply and deliver each block through the channel's outbound."""
129+
for block in channel.chunk(text):
130+
if block.strip():
131+
await channel.deliver(peer_id, block)

0 commit comments

Comments
 (0)