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.
Much of this architecture is already implemented: the @owlat/channels adapter package, the unifiedMessages and channelConfigs tables, the contactIdentities and contactRelationships CRM tables (apps/api/convex/schema/messaging.ts, apps/api/convex/schema/contacts.ts), the live inbound webhook routes that now feed the agent pipeline, AES-256-GCM encryption-at-rest for channel credentials, an exact-identifier contact auto-merge cron, a 5-minute channel health-check cron (apps/api/convex/crons.ts), and an outbound dispatch path that decrypts creds and calls the SMS/WhatsApp/generic adapters. What follows is therefore the shipped backbone plus where the vision still runs ahead of the code: outbound dispatch is wired to the AI-agent reply path and the manual admin composer but stays inert until provider credentials exist, automatic relationship extraction has no caller yet, and health-driven outbound queueing is still on the roadmap.
Channel adapter interface
Channel adapters are pluggable TypeScript classes that normalize different communication channels into a unified message format. They live in the @owlat/channels package (packages/channels/src/types.ts) and run inside Convex — no separate services, no additional infrastructure.
type ChannelType = 'email' | 'sms' | 'whatsapp' | 'generic' | 'chat'
interface ChannelAdapter {
/** Unique channel identifier */
id: ChannelType
/** 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(headers: Record<string, string>, body: string): 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 {
contactId: string
channel: ChannelType
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 API | Webhook from provider | Cloud API |
| WhatsApp Business API | Webhook from Meta | Cloud API | |
| Generic | HTTP POST to external URL | Shared-secret HTTP POST from external system | Any |
| Chat | Convex real-time (native) | Convex mutation (native) | Built-in |
The email adapter (packages/channels/src/email.ts) is a thin shim: its send() deliberately returns success: false, because outbound email does not flow through the adapter at all — it goes through the existing Send worker (apps/api/convex/delivery/worker.ts), which dispatches to the configured provider (MTA / SES / Resend) via apps/api/convex/lib/sendProviders/. The SMS, WhatsApp, and generic adapters carry real Twilio/Meta/HTTP send clients in the @owlat/channels package, and a Convex dispatch action now drives them: dispatchOutbound (apps/api/convex/channels/outbound.ts) decrypts the channel's credentials, instantiates the matching adapter, sends, and records a unifiedMessages outbound row (see callout below). The chat adapter is native — sendChatMessage writes directly into Convex tables with real-time subscription updates.
Inbound is live for SMS, WhatsApp, and the generic channel (the webhook routes below). The outbound dispatch path is built and has two live callers today — the AI-agent reply path (sendApprovedReply in apps/api/convex/agent/agentPipeline.ts) and the manual admin composer (sendChannelMessage in apps/api/convex/channels/outbound.ts). Both schedule dispatchOutbound, which decrypts creds, calls the adapter, and records the result with a fail-safe failed-row contract that never throws. It stays a no-op (recording Channel not configured) only until an operator configures provider credentials. Email and chat send for real today; SMS/WhatsApp/generic are one set of provider creds away.
Unified message model
Conversational messages across all channels flow into a single unifiedMessages table — SMS, WhatsApp, generic, and chat in full, plus conversational email (inbound messages and confirmed agent replies, mirrored in idempotently via the by_external_message_id index). Campaign, transactional, and automation email is deliberately excluded — those are not conversation turns and surface in the contact's Activity tab via contactActivities instead. The table:
unifiedMessages: defineTable({
threadId: v.id('conversationThreads'),
channel: v.union(
v.literal('email'),
v.literal('sms'),
v.literal('whatsapp'),
v.literal('generic'),
v.literal('chat')
),
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 }
contentVersion: v.optional(v.number()), // schema version of the content blob
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_channel', ['channel'])
.index('by_contact', ['contactId'])
.index('by_created_at', ['createdAt'])
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({
channel: v.union(
v.literal('email'),
v.literal('sms'),
v.literal('whatsapp'),
v.literal('generic'),
v.literal('chat')
),
isEnabled: v.boolean(),
displayName: v.optional(v.string()),
config: v.optional(v.string()), // JSON: provider-specific (API keys, phone numbers, etc.)
// Health monitoring — folded directly onto the config row
healthStatus: v.optional(v.union(
v.literal('healthy'),
v.literal('degraded'),
v.literal('down')
)),
lastHealthCheckAt: v.optional(v.number()),
lastSuccessfulSend: v.optional(v.number()),
lastError: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_channel', ['channel'])
The config field holds a provider-specific JSON string (API keys, phone numbers, etc.). For self-hosters, SMS and WhatsApp require API keys from the respective providers — these are the only external dependencies that cannot be self-hosted.
updateChannelConfig (apps/api/convex/unifiedMessages.ts) never writes the config blob in plaintext. It runs in the v8 runtime, so it schedules the Node action encryptAndPersistConfig (apps/api/convex/channels/outbound.ts), which wraps the plaintext in an AES-256-GCM envelope via apps/api/convex/lib/credentialCrypto.ts and patches the row through setChannelConfigSecret — the sole writer of the config column. getChannelConfigInternal decrypts it back inside dispatchOutbound for sending. The channelConfigs.config column therefore only ever holds an encrypted envelope on disk, the same scheme already used for externalMailAccounts. Inbound provider secrets (Twilio auth token, Meta app secret, generic shared secret) used to verify webhook signatures are still read from environment variables, not this row.
Adapter health monitoring
Each adapter exposes a healthCheck() method. A Convex cron job (channel health checks in apps/api/convex/crons.ts) runs every 5 minutes, iterates the enabled channelConfigs rows, and writes the latest status back onto the row via the healthStatus, lastHealthCheckAt, lastSuccessfulSend, and lastError fields shown above. Today the cron's logic is intentionally lightweight: email and chat are marked healthy, and SMS/WhatsApp/generic are marked down when no credentials are configured and healthy otherwise (deeper per-provider probes happen inside the adapter's healthCheck() when sending is wired).
The intended health-driven behavior, once outbound send is fully wired, is:
- Degraded — outbound messages queue with backoff instead of immediate send, and the dashboard shows a warning.
- Down — outbound messages queue for later delivery; inbound webhooks continue to accept and store messages (the provider is still delivering to us, even if we cannot send back), and the dashboard shows an alert with the last error.
The goal is to prevent silent failures — if the Twilio API goes down, support agents see the degradation in their dashboard before customers report missing SMS replies.
The 5-minute health-check cron and the healthStatus/lastError fields are live. The degraded/down queue-with-backoff behavior above describes the intended design — it is not implemented yet. The outbound dispatch path (dispatchOutbound) now has live callers — the agent reply path and the manual composer (see "Outbound dispatch is wired, but inert until creds exist" under "Built-in adapters") — but no send path consults healthStatus before dispatching, so health state does not yet gate or queue sends.
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}
→ Inbound pipeline rate-limits the request
→ Adapter verifySignature() verifies webhook authenticity (Twilio HMAC, Meta X-Hub-Signature-256, generic shared secret)
→ Adapter parseEvent() normalizes the payload
→ Store in unifiedMessages (find-or-create contact + thread first)
→ Feed into the Agent Pipeline (live — project onto an inboundMessages row and start the agent walker)
The HTTP routes live in apps/api/convex/http.ts; their handlers in apps/api/convex/webhooks/channels.ts are thin shells that call runInboundPipeline() (apps/api/convex/webhooks/pipeline.ts), which delegates signature verification and parsing to the per-provider inbound adapters under apps/api/convex/webhooks/adapters/ (twilio.ts, meta.ts, generic.ts). The signature checks are enforced — a missing or invalid signature is rejected before any message is stored. (Note: these inbound adapters are distinct from the @owlat/channels outbound ChannelAdapter classes described above.) After storing the unifiedMessages row, processInboundChannel projects the channel message onto an inboundMessages row and schedules internal.agent.walker.start — so SMS/WhatsApp/generic inbound now flows through the same agent pipeline as email. That step is best-effort and gated on the ai.agent flag: a disabled or misconfigured agent never turns a stored inbound into a 5xx that would make the provider retry. The route registrations:
// SMS inbound (Twilio)
http.route({
path: '/webhooks/sms',
method: 'POST',
handler: handleSmsWebhook,
})
// WhatsApp inbound (Meta)
http.route({
path: '/webhooks/whatsapp',
method: 'POST',
handler: handleWhatsAppWebhook,
})
// WhatsApp verification challenge — Meta requires a GET handshake
http.route({
path: '/webhooks/whatsapp',
method: 'GET',
handler: handleWhatsAppWebhook,
})
// Generic shared-secret webhook (for custom integrations)
http.route({
path: '/webhooks/channel',
method: 'POST',
handler: handleGenericWebhook,
})
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
- Exact-identifier auto-merge → when two live contacts share an exact strong identifier (email / phone / sms / whatsapp), the
auto-merge duplicate contactscron (autoMergeDuplicatesinapps/api/convex/contacts/identities.ts, every 6h) folds the duplicates into the oldest contact without human confirmation. Strong channels are the unambiguous subset; weak/social handles (twitter, generic, chat) are intentionally excluded. - Suggested merge → for everything outside that exact-strong-identifier subset (e.g. fuzzy or social-handle overlaps),
getMergeSuggestionssurfaces a merge suggestion in the contact's Identities tab for a human to confirm rather than merging automatically.
Relationship intelligence
The vision is for the Knowledge Graph to feed relationship insights into the CRM. The contactRelationships table and its manual upsert are shipped today; relationships are operator-entered. An automated extraction path would add a new source branch and a writer that mines conversations — neither exists yet:
contactRelationships: defineTable({
fromContactId: v.id('contacts'),
toContactId: v.id('contacts'),
relationship: v.string(), // "manager_of", "colleague", "reports_to", etc.
confidence: v.number(), // 0-1
source: v.literal('manual'),
createdAt: v.number(),
})
.index('by_from', ['fromContactId'])
.index('by_to', ['toContactId'])
The CRM view for each contact is designed to show — live today:
- Unified timeline — conversational messages across channels (SMS/WhatsApp/generic/chat plus inbound email and agent replies) in chronological order; bulk campaign/transactional/automation email lives in the Activity tab, not here
- Knowledge summary — key facts, preferences, and goals from the Knowledge Graph
and on the roadmap (these depend on the relationship/sentiment extraction that is not yet wired — see the callout below):
- 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
The intent is for all of this to happen through the Knowledge Graph extraction pipeline — the CRM as a view on the knowledge graph, not a separate data store.
The unified timeline (apps/web UnifiedTimelineTab / useUnifiedContactTimeline over getContactTimeline), contact identities, exact-identifier auto-merge, manual relationships, and the merge-suggestion UI are all live. Per-message knowledge extraction is now wired into the inbound pipeline too: once a message is classified, processingLifecycle.ts schedules internal.knowledge.extraction.extractFromMessage (idempotent, best-effort) for both inbound email and inbound channel messages. What is not yet wired is the CRM relationship half of the self-building behavior — auto-logging extracted facts into contactRelationships, sentiment trends, deal/pipeline status, and job-change detection from email signatures. contactRelationships (apps/api/convex/contacts/relationships.ts) carries only the manual source today, so relationships remain operator-entered. Treat the relationship/CRM-derivation layer as the target design.