Message Routing and Security Pipeline
ChannelMessageRouter is the core hub of the Channel system, responsible for receiving inbound messages from all Channel plugins, processing them through a security pipeline, matching trigger rules, routing to Agent processing, and formatting responses back to the original platform.
Source location: packages/desktop/app/main/services/capabilities/integrations/channel/ChannelMessageRouter.ts
Complete Processing Pipeline
sequenceDiagram
participant Plugin as Channel Plugin
participant Registry as ChannelPluginRegistry
participant Router as ChannelMessageRouter
participant RL as RateLimiter
participant IS as InputSanitizer
participant PG as PromptGuardian
participant UP as UserPermissionService
participant Gate as ChannelPermissionGate
participant Bridge as ChannelMagiBridge
participant Agent as MagiService
Plugin->>Registry: emitMessage(InboundMessage)
Registry->>Router: emit('message', ChannelMessage)
Note over Router: Security pipeline starts
Router->>RL: check(channelId, senderId)
alt Rate limit exceeded
RL-->>Router: { allowed: false }
Note over Router: Silently discard
end
Router->>IS: sanitize(content)
IS-->>Router: { content, modified, warnings }
Note over Router: Replace with sanitized content
Router->>PG: review(content, context)
alt Injection detected
PG-->>Router: { allowed: false, deflectionMessage }
Router->>Registry: sendMessage(deflectionMessage)
Note over Router: Message blocked
end
Router->>UP: checkPermission(channelId, senderId, name)
alt User is blocked
UP-->>Router: { allowed: false, role: 'blocked' }
Router->>Registry: sendMessage("You have been blocked")
Note over Router: Message blocked
end
Router->>Gate: processReply(channelId, chatId, senderId, content)
alt Is permission confirmation reply
Gate-->>Router: consumed = true
Note over Router: Message consumed by confirmation mechanism
end
Note over Router: Security pipeline ends, enter trigger matching
Router->>Router: shouldTrigger(msg, config)
alt Trigger rules not matched
Note over Router: bufferMessage (up to 50)
else Trigger rules matched
Router->>Registry: emit('routeToAgent', payload)
Bridge->>Agent: handleMessage(incoming)
Agent-->>Bridge: { response }
Bridge->>Router: sendResponse(channelId, chatId, text, type)
Router->>Router: stripInternalTags + splitMessage
Router->>Registry: sendMessage(chunk)
Registry->>Plugin: plugin.sendMessage(chatId, chunk)
end
Security Service Injection
Router injects security services via setter methods; each security layer is optional:
setRateLimiter(limiter: RateLimiter): void
setSanitizer(sanitizer: InputSanitizer): void
setPromptGuardian(guardian: PromptGuardian): void
setUserPermissions(service: UserPermissionService): void
setPermissionGate(gate: ChannelPermissionGate): void
Uninjected security layers are skipped. This allows flexible security configuration for different deployment scenarios.
Trigger Matching Logic
The decision flow of shouldTrigger(msg, config):
1. allowFrom check (common to all patterns)
├── allowFrom is empty or contains "*" → pass
├── senderId in whitelist → pass
└── senderId not in whitelist → return false (no trigger, no cache)
2. Direct message check
└── !msg.isGroup → return true (DMs always trigger)
3. Trigger pattern check
├── No config or mode === 'all' → true
├── ignoreBot && msg.isFromMe → false
├── mode === 'dm_only' → !msg.isGroup
├── mode === 'mention' → content.includes(`@${mentionName}`)
└── mode === 'keyword' → keywords.some(kw => content.toLowerCase().includes(kw.toLowerCase()))
Key details:
allowFromcheck takes priority over trigger patterns and applies to both DMs and groups- DMs always trigger (after
allowFromcheck) and are not affected by trigger patterns ignoreBotcheck happens before trigger pattern matching- Keyword matching is case-insensitive
mentionNamedefaults to'Clawia'
Message Buffering
Messages that don't trigger are not discarded; instead they are cached in memory:
private messageBuffer = new Map<string, ChannelMessage[]>();
Buffer key: ${channelId}:${chatId} (one buffer per Channel instance + chat session)
Buffer behavior:
- Each buffer holds maximum 50 messages (FIFO eviction)
- On trigger, all messages in buffer + trigger message are sent to Agent
- Buffer is cleared after sending
- All buffers are cleared when Router shuts down via
clearBuffers()
Message Formatting
Group messages are wrapped in XML format before being sent to Agent:
<channel_messages>
<message sender="Alice" channel="discord" channel_id="ch-001" chat="general" time="2026-03-19T10:00:00Z">Hello</message>
<message sender="Bob" channel="discord" channel_id="ch-001" chat="general" time="2026-03-19T10:00:05Z">Hi!</message>
<message sender="Alice" channel="discord" channel_id="ch-001" chat="general" time="2026-03-19T10:00:10Z">@Clawia can you help with this</message>
</channel_messages>
DM/direct messages are not wrapped in XML; they are sent directly as raw text in the prompt.
Response Processing
Internal Tag Removal
<internal>...</internal> tags in Agent replies are removed:
function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
This allows the Agent to include metadata for internal use only, without leaking it to external platforms.
Message Splitting
Replies exceeding platform message length limits are automatically split into multiple messages:
function splitMessage(text: string, maxLength: number): string[]
Splitting strategy (priority from high to low):
- Break at newlines
- Break at spaces
- Hard cut at
maxLength
Platform Length Limits
ChannelMessageRouter maintains message length limits for each platform:
| Platform | Max Length |
|---|---|
| discord | 2,000 |
| telegram | 4,096 |
| slack | 4,000 |
| qqbot | 1,500 (type definition) / 2,000 (Router default) |
| line | 5,000 |
| 100,000 | |
| 65,000 | |
| matrix | 65,536 |
| msteams | 28,000 |
| mattermost | 16,383 |
| twitch | 500 |
| irc | 512 |
If the plugin manifest declares maxMessageLength, it overrides the default value.
Attachment Sending
Router also supports sending attachments to Channel:
async sendAttachment(
channelId: string,
chatId: string,
attachment: AttachmentInput,
): Promise<void>
Call chain: Router.sendAttachment() → Registry.sendAttachment() → plugin.sendAttachment(). If the plugin does not implement sendAttachment, an error is thrown.
Configuration Management
// Set/update trigger configuration
setTriggerConfig(channelId: string, config: ChannelTriggerConfig): void
removeTriggerConfig(channelId: string): void
// Set platform message length limit
setMaxLength(pluginType: string, maxLength: number): void
// Clear all message buffers
clearBuffers(): void
Next Steps
- ChannelMagiBridge — How messages flow from Router to Agent
- Channel Plugin SDK — Plugin interface definitions