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 outbound channel adapters (apps/api/convex/channels/adapters/), 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 apps/api/convex/channels/adapters/ (the interface is types.ts) and run inside Convex — no separate services, no additional infrastructure.
// The channel discriminator is declared once, by the validators the schema and
// every Convex function argument already use (`lib/convexValidators.ts`), and
// derived everywhere else — including the web config form's channel type:
// type UnifiedMessageChannel = 'email' | 'sms' | 'whatsapp' | 'generic' | 'chat'
// type OutboundChannel = 'sms' | 'whatsapp' | 'generic' // has an adapter
interface ChannelAdapter {
/**
* Which channel this adapter dispatches — `OutboundChannel`, the
* dispatchable subset ('sms' | 'whatsapp' | 'generic'), so an adapter
* claiming `email` or `chat` is a compile error rather than a convention.
*/
id: OutboundChannel
/** Send a message through this channel */
send(message: OutboundMessage): Promise<SendResult>
/** Check delivery status of a sent message */
getDeliveryStatus(externalId: string): Promise<DeliveryStatus>
/** Report current connection health */
healthCheck(): Promise<ChannelHealth>
}
// Exactly the two things `updateChannelHealth` persists. The shape once also
// declared `lastSuccessfulSend`, `rateLimitRemaining` and `latencyMs`: no
// adapter ever set the first two, and the third was measured on every probe
// and then discarded, so D10 dropped all three. Persisting probe latency is a
// schema change, not a member.
interface ChannelHealth {
status: 'healthy' | 'degraded' | 'down'
lastError?: string
}
interface OutboundMessage {
contactId: string
channel: UnifiedMessageChannel // all five — dispatch fails safe on the two with no adapter
content: {
text?: string
html?: string
subject?: string // email only
mediaUrl?: string // SMS/WhatsApp
}
threadId?: string
metadata?: Record<string, string>
}
The contract is outbound only, on purpose. An adapter owns sending, delivery status and connection health, which is what the dispatch action, the delivery-status cron and the health cron call. It owns nothing inbound: signature verification and payload normalization live in apps/api/convex/webhooks/adapters/{twilio,meta,generic}.ts, on the real HTTP route, against real secrets. The interface did once declare a parseInbound and a validateSignature as well — a second copy of rules the webhook adapters already owned, with no caller and a drift the compiler could not see — so the D10 honesty pass deleted the pair rather than keep two answers to one question.
Adding a new channel therefore means two things, not one: implement this interface for the outbound half, and add a webhooks/adapters/ module plus its route for the inbound half — 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 |
Only three of the five rows are adapter classes. Email and chat are channels of the unified message model, not channel providers: outbound email goes through the Send worker (apps/api/convex/delivery/worker.ts) to a configured send provider (MTA / SES / Resend / Mandrill) via apps/api/convex/lib/sendProviders/, and chat is native — sendChatMessage writes straight into Convex tables with real-time subscription updates. Both once had a placeholder class implementing the interface above; each faked its answers (a send() that hard-returned failure or a fabricated id, a healthCheck() that hard-returned healthy, a validateSignature() that hard-returned true) and neither had a caller, so both were deleted rather than kept as a shim.
The SMS, WhatsApp, and generic adapters are real Twilio/Meta/HTTP send clients (apps/api/convex/channels/adapters/), and a Convex dispatch action 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).
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 and verify token, generic shared secret) come from the same row: the v8 webhook routes cannot decrypt it themselves, so they read it through the Node action channels.credentials.getInboundSecret and fall back to the deployment environment variable when the channel stores none.
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 result back through updateChannelHealth, which patches healthStatus, lastHealthCheckAt and lastError. Email and chat have no adapter, so config presence is the only signal and they are marked healthy; an SMS/WhatsApp/generic row with no credentials is marked down without a network call; a configured one runs the adapter's real probe (probeChannelHealth, which needs node:crypto to decrypt the stored creds and so lives in a 'use node' action) and reports degraded/down when the provider rejects it.
lastSuccessfulSend is not a health-cron field despite sitting on the same row: unifiedMessages.recordOutbound stamps it off an actual delivered send. A probe reports connectivity, not a send, so ChannelHealth carries only status and lastError.
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 outbound ChannelAdapter classes described above, and they are the only place an inbound channel signature is verified.) 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.