Multi-Channel & CRM
Technical architecture for channel adapters, unified messaging, contact identity unification, and the CRM hub.
Multi-Channel & CRM Architecture
Owlat starts with email. But the Communication Hub is channel-agnostic — every message becomes a structured event in the same pipeline, regardless of where it originates. This page covers the technical architecture for multi-channel support and the CRM hub that unifies contacts across all channels.
Channel adapter interface
Channel adapters are pluggable TypeScript classes that normalize different communication channels into a unified message format. They run as Convex actions — no separate services, no additional infrastructure.
interface ChannelAdapter {
/** Unique channel identifier */
id: 'email' | 'sms' | 'whatsapp' | 'webhook' | 'chat'
/** Send a message through this channel */
send(message: OutboundMessage): Promise<SendResult>
/** Parse an inbound webhook payload into a unified message */
parseInbound(raw: unknown): ParsedMessage
/** Check delivery status of a sent message */
getDeliveryStatus(externalId: string): Promise<DeliveryStatus>
/** Validate an inbound webhook signature */
validateSignature(request: Request): Promise<boolean>
/** Report current connection health */
healthCheck(): Promise<ChannelHealth>
}
interface ChannelHealth {
status: 'healthy' | 'degraded' | 'down'
lastSuccessfulSend?: number // Timestamp
lastError?: string
rateLimitRemaining?: number // Provider rate limit headroom
latencyMs?: number // Average send latency
}
interface OutboundMessage {
organizationId: string
contactId: string
channel: string
content: {
text?: string
html?: string
subject?: string // email only
mediaUrl?: string // SMS/WhatsApp
}
threadId?: string
metadata?: Record<string, string>
}
Every adapter owns its full lifecycle — not just message send/receive, but connection health, signature validation, and rate limit awareness. This prevents duplicated validation logic across webhook handlers and gives the monitoring system a unified health surface.
Adding a new channel means implementing this interface and registering an inbound webhook endpoint in the Convex HTTP router — the same pattern used for the existing Resend and MTA webhooks.
Built-in adapters
| Adapter | Outbound | Inbound | Provider |
|---|---|---|---|
| MTA / SES / Resend (existing) | MTA inbound SMTP | Self-hosted or cloud | |
| SMS | Twilio / Vonage API | Webhook from provider | Cloud API |
| WhatsApp Business API | Webhook from Meta | Cloud API | |
| Webhook | HTTP POST to external URL | HTTP POST from external system | Any |
| Chat | Convex real-time (native) | Convex mutation (native) | Built-in |
The email adapter wraps the existing email provider system (apps/api/convex/lib/emailProviders/). SMS and WhatsApp adapters are API clients that run within Convex actions. The chat adapter is native — messages go directly into Convex tables with real-time subscription updates.
Unified message model
All messages across all channels flow into a single unifiedMessages table:
unifiedMessages: defineTable({
organizationId: v.string(),
threadId: v.id('conversationThreads'),
channel: v.string(),
direction: v.union(v.literal('inbound'), v.literal('outbound')),
contactId: v.optional(v.id('contacts')),
memberId: v.optional(v.string()), // internal sender (BetterAuth user ID)
content: v.string(), // JSON: { text, html, subject, mediaUrl }
externalMessageId: v.optional(v.string()),
status: v.union(
v.literal('received'),
v.literal('queued'),
v.literal('sent'),
v.literal('delivered'),
v.literal('read'),
v.literal('failed')
),
metadata: v.optional(v.string()), // Channel-specific metadata (JSON)
createdAt: v.number(),
})
.index('by_thread', ['threadId'])
.index('by_organization_and_channel', ['organizationId', 'channel'])
.index('by_contact', ['contactId'])
The conversationThreads table (introduced in the Agent Pipeline) becomes the universal hub. A thread can contain email messages, SMS messages, chat messages, and webhook events — all in chronological order.
Channel configuration
Each organization configures which channels are active and how they connect:
channelConfigs: defineTable({
organizationId: v.string(),
channel: v.string(),
enabled: v.boolean(),
config: v.string(), // JSON: provider-specific (API keys, phone numbers, etc.)
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_organization', ['organizationId'])
.index('by_organization_and_channel', ['organizationId', 'channel'])
Channel configurations store encrypted credentials. For self-hosters, SMS and WhatsApp require API keys from the respective providers — these are the only external dependencies that cannot be self-hosted.
Adapter health monitoring
Each adapter reports its connection health via healthCheck(). A Convex cron job polls adapter health every 5 minutes and updates channelConfigs with the latest status:
channelHealth: v.optional(v.object({
status: v.union(v.literal('healthy'), v.literal('degraded'), v.literal('down')),
lastCheckedAt: v.number(),
lastSuccessfulSend: v.optional(v.number()),
lastError: v.optional(v.string()),
rateLimitRemaining: v.optional(v.number()),
})),
When an adapter reports degraded or down status:
- Degraded — outbound messages queue with exponential backoff instead of immediate send. The dashboard shows a warning.
- Down — outbound messages queue for later delivery. Inbound webhooks continue to accept and store messages (the provider is sending, even if we cannot send back). The dashboard shows an alert with the last error.
This prevents silent failures — if the Twilio API goes down, support agents see the degradation in their dashboard before customers report missing SMS replies.
Inbound webhook pattern
Each channel's inbound messages arrive via HTTP webhook. The pattern is identical to the existing MTA and Resend webhook handlers:
External provider (Twilio, Meta, etc.)
→ POST /webhooks/{channel}
→ Channel adapter validateSignature() verifies webhook authenticity
→ Channel adapter parseInbound() normalizes the payload
→ Store in unifiedMessages
→ Link to conversationThread (or create new thread)
→ Message coalescing check (debounce window for thread bursts)
→ Feed into Agent Pipeline (if configured)
New routes added to apps/api/convex/http.ts:
// SMS inbound
http.route({
path: '/webhooks/sms',
method: 'POST',
handler: handleSmsWebhook,
})
// WhatsApp inbound
http.route({
path: '/webhooks/whatsapp',
method: 'POST',
handler: handleWhatsAppWebhook,
})
// Generic webhook (for custom integrations)
http.route({
pathPrefix: '/webhooks/custom/',
method: 'POST',
handler: handleCustomWebhook,
})
CRM Hub
The CRM hub extends the existing contacts table with unified identity management and relationship intelligence.
Contact identity unification
The same person often communicates through multiple channels — work email, personal email, phone, WhatsApp. The CRM unifies these into a single contact profile:
contactIdentities: defineTable({
contactId: v.id('contacts'),
channel: v.string(), // 'email', 'phone', 'whatsapp', 'twitter', etc.
identifier: v.string(), // email address, phone number, handle
isPrimary: v.boolean(),
verifiedAt: v.optional(v.number()),
createdAt: v.number(),
})
.index('by_contact', ['contactId'])
.index('by_identifier', ['channel', 'identifier'])
When an inbound message arrives, the pipeline checks contactIdentities for a matching identifier:
- Match found → link to existing contact, update thread
- No match → create a new contact and identity
- Suggested merge → when signals suggest two contacts are the same person (email signature contains a phone number already linked to another contact), surface a merge suggestion in the UI
Relationship intelligence
The Knowledge Graph feeds relationship insights into the CRM:
contactRelationships: defineTable({
organizationId: v.string(),
fromContactId: v.id('contacts'),
toContactId: v.id('contacts'),
relationship: v.string(), // "manager_of", "colleague", "reports_to", etc.
confidence: v.number(),
source: v.union(v.literal('manual'), v.literal('agent_extracted')),
createdAt: v.number(),
})
.index('by_from', ['fromContactId'])
.index('by_to', ['toContactId'])
.index('by_organization', ['organizationId'])
The CRM view for each contact shows:
- Unified timeline — all messages across all channels in chronological order
- Knowledge summary — key facts, preferences, and goals from the Knowledge Graph
- Relationship map — connections to other contacts (colleagues, reports, etc.)
- Sentiment trend — how the contact's sentiment has evolved over time
- Outstanding commitments — promises made in conversations ("you said you'd send the proposal by Tuesday")
- Communication preferences — preferred channel, response patterns, active hours
Communication-native updates
Traditional CRMs require manual data entry. In Owlat, the CRM builds itself from actual communication:
- When you email an investor → the interaction is logged automatically
- When a customer's sentiment shifts negative → the relationship health score updates
- When a deal is discussed in a thread → the pipeline status reflects the conversation
- When a contact changes jobs (detected from email signature changes) → the profile updates
All of this happens through the Knowledge Graph extraction pipeline — the CRM is a view on the knowledge graph, not a separate data store.