Channels are where the runtime meets the world. Telegram is the first one openclaw-channels ships in full — and the reference implementation for the rest.
The Channel trait shape
pub trait Channel: Send + Sync {
fn id(&self) -> ChannelId;
fn name(&self) -> &str;
}
#[async_trait]
pub trait ChannelInbound: Channel {
async fn run(&self, sink: mpsc::Sender<InboundEvent>) -> Result<()>;
}
#[async_trait]
pub trait ChannelOutbound: Channel {
async fn send(&self, to: PeerId, message: OutboundMessage) -> Result<MessageId>;
}
A real channel implements both ChannelInbound (it produces events) and ChannelOutbound (it accepts replies). The runtime orchestrates: events go through the routing rules → an agent → a reply back through the same channel.
Allowlisting
Before an inbound event reaches an agent, it goes through the allowlist:
pub struct Allowlist {
rules: Vec<AllowRule>,
}
pub enum AllowRule {
PeerId(PeerId),
PeerIdPrefix(String),
GroupId(GroupId),
Wildcard, // dangerous; opt-in
}
impl Allowlist {
pub fn allows(&self, event: &InboundEvent) -> bool { ... }
}
This isn’t optional. A new Telegram channel starts with Allowlist::default() (deny-all) and you have to add explicit AllowRule entries before it’ll process anything. If you forget, the bot is silently inert. We prefer “silently inert” over “open to the entire internet.”
Routing rules
After allowlist, routing decides which agent gets the message:
pub struct Router {
rules: Vec<RouteRule>,
}
pub struct RouteRule {
pub priority: u32,
pub matcher: RouteMatcher,
pub target: AgentId,
}
pub enum RouteMatcher {
PeerId(PeerId),
Keyword(String),
Regex(Regex),
Always,
}
Rules sort by priority. First match wins. Always is the catch-all fallback. A typical config:
let router = Router::new()
.add(100, RouteMatcher::Keyword("/code".into()), AgentId("code-agent".into()))
.add(50, RouteMatcher::Keyword("/help".into()), AgentId("help-agent".into()))
.add(0, RouteMatcher::Always, AgentId("default".into()));
The Telegram adapter
The Telegram Bot API is just HTTP. Polling for updates is getUpdates. Sending is sendMessage, sendPhoto, sendDocument. The Rust adapter is built on reqwest and a thin set of types.
pub struct TelegramChannel {
id: ChannelId,
bot_token: ApiKey,
client: reqwest::Client,
allowlist: Allowlist,
}
#[async_trait]
impl ChannelInbound for TelegramChannel {
async fn run(&self, sink: mpsc::Sender<InboundEvent>) -> Result<()> {
let mut offset = 0i64;
loop {
let updates = self.get_updates(offset).await?;
for u in updates {
offset = u.update_id + 1;
if !self.allowlist.allows_update(&u) { continue; }
let event = self.into_inbound(u)?;
sink.send(event).await?;
}
}
}
}
Note ApiKey for the bot token — same redaction guarantees as the LLM keys.
Attachments
Telegram messages can carry photos, documents, voice notes, video. The adapter normalises these into:
pub struct Attachment {
pub kind: AttachmentKind, // Photo, Document, Voice, Video
pub mime: String,
pub bytes: Vec<u8>,
pub filename: Option<String>,
}
The runtime applies the same validation limits as for text:
- Max 50 MB per attachment.
- Max 10 attachments per message.
- MIME type checked against
application/octet-streamfamily for plausibility.
Outbound
ChannelOutbound::send is the symmetric path. The runtime calls it after an agent produces a response:
#[async_trait]
impl ChannelOutbound for TelegramChannel {
async fn send(&self, to: PeerId, message: OutboundMessage) -> Result<MessageId> {
let chat_id: i64 = to.parse()?;
let resp = self.client
.post(format!("https://api.telegram.org/bot{}/sendMessage",
self.bot_token.expose_secret()))
.json(&json!({
"chat_id": chat_id,
"text": message.content,
"parse_mode": "MarkdownV2",
}))
.send().await?
.error_for_status()?;
// ...
}
}
Configuration
In openclaw.json:
{
"channels": {
"telegram": {
"enabled": true,
"bot_token": "{{ ANTHROPIC_TELEGRAM_TOKEN }}", // resolved from CredentialStore
"allowlist": [
{ "kind": "PeerId", "value": "123456789" },
{ "kind": "GroupId", "value": "-1001234567890" }
],
"routing": [
{ "priority": 100, "match": { "kind": "Keyword", "value": "/code" }, "target": "code-agent" },
{ "priority": 0, "match": { "kind": "Always" }, "target": "default" }
]
}
}
}
{{ ANTHROPIC_TELEGRAM_TOKEN }} is resolved from the credential store at load time. It never sits in the config file as plaintext.
How to use this as a template
The Telegram adapter is the reference shape for every other channel. Discord, Slack, Signal, Matrix, WhatsApp — each is a module that implements Channel + ChannelInbound + ChannelOutbound and registers itself via ChannelRegistry::register().
If you want to add one, the reading list is:
crates/openclaw-channels/src/lib.rs— the traits.crates/openclaw-channels/src/telegram/— the reference implementation.docs/CONTRIBUTING.md— the PR checklist.
PRs welcome.