Interfaces Overview
Multi-channel messaging architecture powering Aleph's polymorphic communication
Aleph communicates with users through interfaces (also called channels) — pluggable adapters that bridge messaging platforms to the Gateway's unified message bus. A single Aleph instance can simultaneously serve Telegram, Discord, iMessage, WebChat, CLI, and the rest of the supported channel family, all sharing the same agent brain, memory, and tool ecosystem.
Architecture
Every interface follows the same data flow:
Platform API ──> Interface Adapter ──> InboundMessage ──> ChannelRegistry
│
InboundMessageRouter
(single source: access,
pairing, allowlist,
session key, agent binding)
│
Harness
│
Platform API <── Interface Adapter <── OutboundMessage <── ResponseThe ChannelRegistry (src/gateway/channel_registry.rs) manages the lifecycle of all active interfaces. At startup it reads the channel configuration, instantiates each enabled interface via its ChannelFactory, calls start(), and exposes a shared broadcast::Receiver<InboundMessage> plus a typed outbound send path. From that point every inbound message lands on one queue, and every outbound reply is dispatched through the same adapter.
The InboundMessageRouter (src/gateway/inbound_router/) is the single source for access policy, pairing, allowlist enforcement, deduplication, group-chat handling, and session-key resolution. Each channel adapter hands the router a raw message; the adapter only performs a coarse pre-filter (drop obvious traffic before reaching the router); the router is the only place that mints pairing codes and decides who reaches the agent. Pairing state is persisted in the single PairingStore (src/gateway/pairing_store.rs).
The Channel Trait
All interfaces implement the Channel trait defined in src/gateway/channel.rs:738. It provides a uniform API regardless of the underlying platform:
#[async_trait]
pub trait Channel: Send + Sync {
/// Channel metadata (id, name, type, status, capabilities)
fn info(&self) -> &ChannelInfo;
/// Shared mutable state (status + inbound broadcast sender/receiver)
fn state(&self) -> &ChannelState;
/// Channel id (default: `info().id`)
fn id(&self) -> &ChannelId { &self.info().id }
/// Channel type (default: `info().channel_type`)
fn channel_type(&self) -> &str { &self.info().channel_type }
/// Current status (default: `state().status()`)
fn status(&self) -> ChannelStatus { self.state().status() }
/// Current health probe (default: `state().health()`)
async fn health(&self) -> ChannelHealth { self.state().health().await }
/// Capability table (default: `info().capabilities`)
fn capabilities(&self) -> &ChannelCapabilities { &self.info().capabilities }
/// Approval delivery capability (None by default; channels opt in)
fn approval_capability(&self) -> Option<Arc<dyn ChannelApprovalCapability>> { None }
/// Pairing payload (QR, code, ...) for channels that surface pairing
async fn get_pairing_data(&self) -> ChannelResult<PairingData> { Ok(PairingData::None) }
/// Active (non-expired) pairing codes — `(code, remaining_ttl_secs)`
async fn list_active_pairing_codes(&self) -> ChannelResult<Vec<(String, u64)>> { Ok(vec![]) }
/// Start the channel (connect, authenticate, begin polling/listening)
async fn start(&mut self) -> ChannelResult<()>;
/// Stop the channel (disconnect, cleanup)
async fn stop(&mut self) -> ChannelResult<()>;
/// Send a message through this channel
async fn send(&self, message: OutboundMessage) -> ChannelResult<SendResult>;
/// Subscribe to inbound messages — multiple subscribers each get their own
/// `broadcast::Receiver<InboundMessage>`
fn inbound_subscribe(&self) -> broadcast::Receiver<InboundMessage> {
self.state().inbound_subscribe()
}
/// Send a typing indicator
async fn send_typing(&self, conversation_id: &ConversationId) -> ChannelResult<()>;
/// Mark a message as read
async fn mark_read(&self, message_id: &MessageId) -> ChannelResult<()>;
/// React to a message
async fn react(&self, conversation_id: &ConversationId,
message_id: &MessageId, reaction: &str) -> ChannelResult<()>;
/// Edit a previously sent message
async fn edit(&self, conversation_id: &ConversationId,
message_id: &MessageId, new_text: &str) -> ChannelResult<()>;
/// Delete a message
async fn delete(&self, conversation_id: &ConversationId,
message_id: &MessageId) -> ChannelResult<()>;
/// List conversations the agent can address (read-only, routing metadata)
async fn list_conversations(&self, query: &str, limit: usize)
-> ChannelResult<ConversationPage>;
/// Native streaming handler (channels with `StreamProtocol::Native`)
fn native_stream_handler(&self) -> Option<Arc<dyn NativeStreamHandler>> { None }
}Each interface also has a ChannelFactory that creates instances from JSON configuration:
#[async_trait]
pub trait ChannelFactory: Send + Sync {
/// Channel type this factory creates (e.g. "telegram", "discord")
fn channel_type(&self) -> &str;
/// Create a channel instance from configuration
async fn create(&self, config: serde_json::Value) -> ChannelResult<Box<dyn Channel>>;
}Channel Capabilities
Not every platform supports every feature. Each channel declares a ChannelCapabilities struct (src/gateway/channel.rs:384) so the agent can adapt its behavior:
pub struct ChannelCapabilities {
pub attachments: bool,
pub images: bool,
pub audio: bool,
pub video: bool,
pub reactions: bool,
pub replies: bool,
pub editing: bool,
pub deletion: bool,
pub typing_indicator: bool,
pub read_receipts: bool,
pub rich_text: bool,
pub max_message_length: usize, // 0 = unlimited
pub max_attachment_size: u64, // 0 = unlimited
pub stream_protocol: StreamProtocol, // None | EditBased | Native
}A separate StreamProtocol enum distinguishes channels that buffer until completion (None), edit a single draft message as tokens arrive (EditBased — Telegram, Discord), or handle streaming natively (Native — Teams).
Channel Comparison Table
| Feature | Telegram | Discord | iMessage (Local) | iMessage (BlueBubbles) | WebChat | CLI |
|---|---|---|---|---|---|---|
| Rich text (Markdown) | Yes | Yes | No | No | Yes | Yes |
| Attachments | Yes | Yes | Yes | Yes | Yes | No |
| Images | Yes | Yes | Yes | Yes | Yes | No |
| Audio/Video | Yes | Yes | Yes | Yes | No | No |
| Reactions | Yes | Yes | Tapbacks (inbound) | Tapbacks | No | No |
| Reply threading | Yes | Yes | No | Yes | No | No |
| Message editing | Yes | Yes | No | No | No | No |
| Message deletion | Yes | Yes | No | No | No | No |
| Typing indicator | Yes | Yes | No | Yes | Visual | No |
| Read receipts | No | No | No | Yes | No | No |
| Inline keyboards | Yes | Yes | No | No | No | No |
| Max message length | 4,096 | 2,000 | ~20,000 | 4,000 | Unlimited | Unlimited |
| Max attachment size | 50 MB | 25 MB | 100 MB | 100 MB | Configurable | N/A |
| Stream protocol | EditBased | EditBased | None | None | EditBased | None |
| Setup difficulty | Easy | Medium | macOS-only | Medium | Included | Included |
Other channels shipped in the same adapter tree (src/gateway/interfaces/): CLI, Email, Feishu, IRC, Line, Matrix, Mattermost, MSteams, Nostr, QQ, Signal, Slack, Webhook, WeChat, WhatsApp, XMPP.
InteractionManifest
Beyond raw capabilities, Aleph uses an InteractionManifest system to inform the AI about what the current channel can do. Each channel belongs to an InteractionParadigm:
| Paradigm | Channels | Capabilities |
|---|---|---|
| CLI | CLI | Rich text, code highlighting, streaming |
| WebRich | WebChat, Desktop apps | Full interactive UI, canvas, mermaid charts, streaming |
| Messaging | Telegram, Discord, iMessage, … | Rich text, inline images |
| Background | Cron jobs, webhooks | Silent reply only |
| Embedded | Minimal UI contexts | No special capabilities |
The manifest tells the AI whether it can use Mermaid diagrams, interactive buttons, canvas drawing, or streaming — so it tailors its output format to what actually renders on the user's screen.
Session Key Resolution
When a message arrives on any interface, the Gateway resolves a SessionKey that determines which conversation history to load. Session keys encode the full context:
| Session Type | Key Format | Example |
|---|---|---|
| Main | agent:{id}:main | agent:main:main |
| DM (per-peer) | agent:{id}:dm:{peer} | agent:main:dm:user123 |
| DM (per-channel-peer) | agent:{id}:{channel}:dm:{peer} | agent:main:telegram:dm:user123 |
| Group | agent:{id}:{channel}:group:{peer} | agent:main:discord:group:guild456 |
| Task | agent:{id}:{type}:{task} | agent:main:cron:daily-summary |
| Ephemeral | agent:{id}:ephemeral:{uuid} | agent:main:ephemeral:a1b2c3 |
The DmScope strategy controls how direct messages are isolated:
Main— All DMs share the main session (conversation merges across users)PerPeer— Each user gets their own session, shared across channels (default)PerChannelPeer— Each user on each channel gets a separate session
Identity Links allow you to map the same person across channels. If user 123 on Telegram and user 456 on Discord are both "john", their DM sessions can be unified under agent:main:dm:john.
Route Binding
You can bind specific channels, users, or guilds to different agents using route bindings. The resolution follows a priority chain:
- Peer — Specific user/chat match (highest priority)
- Guild — Discord guild or Slack workspace match
- Team — Team-level match
- Account — Specific account match
- Channel — Channel-type match (e.g., all Telegram to one agent)
- Default — Falls back to the default agent
# Route all Telegram messages to the "personal" agent
[[bindings]]
agent_id = "personal"
[bindings.match_rule]
channel = "telegram"
account_id = "*"
# Route a specific Discord guild to the "work" agent
[[bindings]]
agent_id = "work"
[bindings.match_rule]
channel = "discord"
guild_id = "123456789"Feature Compilation
Aleph compiles every channel adapter by default; channels are enabled via configuration (config.toml), not compile-time features. The iMessage local transport is gated by #[cfg(target_os = "macos")] — the BlueBubbles transport runs on any OS, so BlueBubblesChannel is available everywhere.
Channel Configuration
All channels are configured through the Aleph config file (~/.aleph/config.toml). Hot-reload applies changes within a short interval:
[channels.telegram]
enabled = true
token = "${TELEGRAM_BOT_TOKEN}"
[channels.discord]
enabled = true
token = "${DISCORD_BOT_TOKEN}"
allowed_guilds = [123456789]
[channels.imessage]
# Local (macOS only): SQLite polling of ~/Library/Messages/chat.db
# AND/OR
# BlueBubbles (any OS): REST + webhook against a BlueBubbles server
enabled = true
db_path = "~/Library/Messages/chat.db"
poll_interval_ms = 1000Configuration supports ${ENV_VAR} expansion for secrets, so tokens never need to be stored in plaintext.
What's Next
Dive into the individual interface guides: