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

AdapterOutboundInboundProvider
EmailMTA / SES / Resend (existing)MTA inbound SMTPSelf-hosted or cloud
SMSTwilio / Vonage APIWebhook from providerCloud API
WhatsAppWhatsApp Business APIWebhook from MetaCloud API
WebhookHTTP POST to external URLHTTP POST from external systemAny
ChatConvex 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:

  1. Match found → link to existing contact, update thread
  2. No match → create a new contact and identity
  3. 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.