The v2 message router handles inbound message evaluation, fan-out to wired agents, and outbound delivery through session databases.
Inbound routing pipeline
The router (src/router.ts) processes inbound messages through these stages:
1. Thread policy
Non-threaded adapters collapse threadId to null:
// Telegram, WhatsApp, iMessage don't support threads
if (!adapter.supportsThreads) {
message.threadId = null;
}
2. Messaging group lookup
Combined query for messaging group and wired agent count. Messaging groups are auto-created only on mentions or DMs — plain chatter is silent.
3. Unwired channel handling
If no agents are wired and it’s a mention, the channel-request gate escalates to the owner for approval.
4. Sender resolution
The permissions module extracts a namespaced user ID and upserts the users row:
// User ID format: channelType:handle
// Examples: phone:+15551234567, tg:123456, discord:789012
5. Fan-out
Each wired agent is evaluated independently. Message IDs are namespaced by agent group ID to prevent collisions.
6. Engage evaluation
Per-agent decision based on the wiring’s engage_mode:
| Mode | Condition |
|---|
pattern | Message matches engage_pattern regex ('.' = always) |
mention | Platform-level mention required |
mention-sticky | Platform mention OR existing active session |
7. Delivery
Engaging agents get a session write and container wake. Non-engaging agents with ignored_message_policy='accumulate' get the message stored with trigger=0.
Module hooks
The router accepts optional pluggable hooks:
| Hook | Purpose |
|---|
setSenderResolver | Runs before agent resolution — extracts user ID |
setAccessGate | Runs after agent resolution — enforces unknown_sender_policy |
setSenderScopeGate | Per-wiring sender scope enforcement |
setChannelRequestGate | Escalation for unwired channels |
All hooks are optional. Without the permissions module, the system is allow-all.
Outbound delivery
Delivery polls
| Poll | Interval | Scope |
|---|
| Active | 1 second | Sessions with running containers |
| Sweep | 60 seconds | All active sessions |
Delivery pipeline
For each session with due outbound messages:
- Read from
outbound.db (read-only)
- Filter already-delivered via
inbound.db’s delivered table
- Route by
kind:
system — dispatch to registered delivery action handlers
channel_type='agent' — agent-to-agent module (see return-path routing)
- Normal — permission check, then channel adapter delivery
- Mark delivered in
inbound.db
- Clean up
outbox/ files (best-effort)
Agent-to-agent return-path routing
When an agent emits a reply to another agent group, the target may have multiple active sessions. The router resolves the destination session in three layers, highest fidelity first:
- Direct return-path — if the outbound carries
in_reply_to, the router opens the source session’s inbound.db, reads the triggering row’s source_session_id, and routes to that session.
- Peer-affinity fallback — when
in_reply_to is absent or doesn’t resolve, the router uses the most recent A2A inbound from the same peer agent group whose source_session_id is non-null.
- Legacy fallback — newest active session for the target agent group (pre-migration compatibility).
The source_session_id column on messages_in is stamped when an outbound A2A message is converted to the target’s inbound row. The container’s MCP send_message and send_file tools thread the current batch’s in_reply_to onto every outbound row so the host can correlate replies back to the originating session.
Delivery actions
Modules register handlers via registerDeliveryAction(action, handler):
registerDeliveryAction('schedule_task', handleScheduleTask);
registerDeliveryAction('cancel_task', handleCancelTask);
// etc.
Retry behavior
- 3 attempts per message
- Permanently failed after exhausting retries
- Attempt counter resets on process restart
Types
EngageMode
type EngageMode = 'pattern' | 'mention' | 'mention-sticky';
SenderScope
type SenderScope = 'all' | 'known';
IgnoredMessagePolicy
type IgnoredMessagePolicy = 'drop' | 'accumulate';
SessionMode
type SessionMode = 'shared' | 'per-thread' | 'agent-shared';
MessageInKind
type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
MessageInStatus
type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
UnknownSenderPolicy
type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public';
Last modified on May 7, 2026