Skip to main content

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:

  • allowFrom check takes priority over trigger patterns and applies to both DMs and groups
  • DMs always trigger (after allowFrom check) and are not affected by trigger patterns
  • ignoreBot check happens before trigger pattern matching
  • Keyword matching is case-insensitive
  • mentionName defaults 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):

  1. Break at newlines
  2. Break at spaces
  3. Hard cut at maxLength

Platform Length Limits

ChannelMessageRouter maintains message length limits for each platform:

PlatformMax Length
discord2,000
telegram4,096
slack4,000
qqbot1,500 (type definition) / 2,000 (Router default)
line5,000
email100,000
whatsapp65,000
matrix65,536
msteams28,000
mattermost16,383
twitch500
irc512

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