feat: add messaging platform types, adapter interface, and registryfeat: add database tables for generic messaging connections and linksfeat: add thread/message service methods for generic messaging linksrefactor: create Slack messaging platform adapter wrapping existing codefeat: add Twilio SMS/Conversations service wrapper and adapterfeat: add SMS inbound webhook route and connection setupfeat: add messaging event dispatcher with group chat supportfeat: add MessagingContext to agent execution typesfeat: add generic messaging agent response Inngest functionfeat: add sms-operations agent toolrefactor: relax thread ownership for group messaging threadstest: add tests for messaging platform adapters, dispatcher, and group chat
Strategy: Additive, not destructive. New abstractions are added alongside existing Slack code. Slack continues using its existing code paths. SMS uses the new abstractions. Once proven in production, Slack handlers can be migrated to the generic layer.
[Inbound Webhooks] → [Platform Adapter] → [Event Dispatcher] → [Agent Execution]
Slack /api/slack/events shared flow startChat()
SMS /api/sms/webhook ↓
[Inngest completion]
[Outbound Response] ← [Platform Adapter] ← [Inngest Function] ← ↓
Slack postMessage messaging/agent.response
SMS Twilio sendSms
type MessagingPlatformId = 'slack' | 'sms'
/** Whether a conversation is private (1:1) or group (multi-user) */
type ConversationScope = 'private' | 'group'
interface PlatformCapabilities {
canSendMessage: boolean
canReceiveMessage: boolean
canReact: boolean
canThread: boolean
canListChannels: boolean
canListUsers: boolean
canSendMedia: boolean
canGroupChat: boolean // Slack channels, MMS group, Discord channels
}
interface InboundMessagingEvent {
platform: MessagingPlatformId
workspaceId: string
externalConversationId: string // Slack channelId, Twilio Conversation SID, phone number (1:1)
externalThreadId: string | null // Slack threadTs, null for SMS
externalMessageId: string // Slack messageTs, Twilio Message SID
externalUserId: string // Slack userId, phone number
text: string
rawPayload: unknown
timestamp: Date
/** Whether this message came from a group conversation */
conversationScope: ConversationScope
/** Participant count (if known from platform) */
participantCount?: number
}
interface OutboundMessageResult {
success: boolean
externalMessageId?: string
externalThreadId?: string
error?: string
}
interface MessagingContext {
platform: MessagingPlatformId
connectionId: string
externalConversationId: string
externalThreadId: string | null
externalConversationName?: string
/** Group vs private — controls thread ownership behavior */
conversationScope: ConversationScope
}interface MessagingPlatformAdapter {
readonly platform: MessagingPlatformId
readonly capabilities: PlatformCapabilities
sendMessage(params: {
connectionId: string
conversationId: string
text: string
threadId?: string
}): Promise<OutboundMessageResult>
verifyWebhook(params: {
headers: Record<string, string | undefined>
rawBody: string
connectionCredentials?: unknown
}): Promise<{ valid: boolean; error?: string }>
normalizeInbound(params: {
body: unknown
headers: Record<string, string | undefined>
workspaceId: string
connectionId: string
}): Promise<InboundMessagingEvent | null>
resolveUser(params: {
workspaceId: string
externalUserId: string
}): Promise<{ userId: string; userName?: string } | null>
formatOutboundText(markdown: string): string // md→mrkdwn for Slack, md→plaintext for SMS
}Follow pattern from apps/api/src/services/webhooks/provider-registry.ts:
registerPlatform(adapter),getPlatform(id),getPlatformOrThrow(id),getAllPlatforms()
Generic connection storage for all messaging platforms. Replaces the need for per-platform connection tables.
| Column | Type | Notes |
|---|---|---|
| pk | serial | |
| id | text unique | makeId('wmc') |
| workspace_id | text FK→workspaces | |
| platform | text | 'slack' / 'sms' |
| credentials | jsonb | Encrypted. Slack: {accessToken}, SMS: {accountSid, authToken, fromNumber} |
| credentials_encrypted | boolean | |
| platform_metadata | jsonb | Slack: {teamId, teamName, botUserId}, SMS: {phoneNumber, friendlyName} |
| installed_by | text FK→users | |
| enabled | boolean default true | |
| created_at, updated_at | timestamp | |
| UNIQUE | (workspace_id, platform) | One connection per platform per workspace |
Platform-agnostic thread linking (replaces threads.slackChannelId / threads.slackThreadTs over time).
| Column | Type | Notes |
|---|---|---|
| pk | serial | |
| id | text unique | makeId('mtl') |
| thread_id | text FK→threads CASCADE | |
| platform | text | |
| connection_id | text FK→workspace_messaging_connections | |
| external_conversation_id | text | Slack channelId, Twilio Conversation SID, phone number |
| external_thread_id | text nullable | Slack threadTs, null for SMS |
| conversation_scope | text default 'private' | 'private' or 'group' — controls who can interact |
| created_at | timestamp | |
| UNIQUE | (thread_id, platform) | One link per platform per thread |
| UNIQUE | (platform, external_conversation_id, external_thread_id) | Lookup by external coords |
Generic user identity mapping (replaces workspace_members.slackUserId over time).
| Column | Type | Notes |
|---|---|---|
| pk | serial | |
| id | text unique | makeId('wmi') |
| workspace_member_id | text FK→workspace_members CASCADE | |
| platform | text | |
| external_user_id | text | Slack userId, phone number |
| created_at | timestamp | |
| UNIQUE | (workspace_member_id, platform) | One identity per platform per member |
Add to apps/api/src/services/thread.service.ts:
linkToMessagingPlatform(threadId, { platform, connectionId, externalConversationId, externalThreadId })findByMessagingPlatform(platform, externalConversationId, externalThreadId)
New service apps/api/src/services/messaging-link.service.ts:
storeMessageLink(messageId, platform, externalMessageId)findMessageLink(messageId, platform)
Wraps existing SlackApiService behind the MessagingPlatformAdapter interface. No behavioral changes.
capabilities: {
canSendMessage: true, canReceiveMessage: true, canReact: true,
canThread: true, canListChannels: true, canListUsers: true, canSendMedia: false,
}sendMessage→ createsSlackApiServicefrom connection credentials, callspostMessage, converts markdown viaslackifyMarkdownverifyWebhook→ delegates to existingverifySlackSignatureresolveUser→ queriesworkspaceMembers.slackUserId(existing column)formatOutboundText→slackifyMarkdown()
Important: Existing Slack event handlers (app-mention.handler.ts, message.handler.ts) continue to work as-is. The adapter is used by the new generic dispatcher for future use, and by the generic outbound response function.
- 1:1 SMS: Use Twilio Messages API (simple
sendSms(to, body)) - Group SMS/MMS: Use Twilio Conversations API (manages multi-participant conversations)
- The adapter transparently picks the right API based on
conversationScope
- Wraps both Twilio Messages API and Conversations API (
twilionpm package) sendSms(to, body)→ 1:1 message, returns{ sid }createConversation(friendlyName)→ group conversation, returns{ conversationSid }addParticipant(conversationSid, phoneNumber)→ add member to groupsendConversationMessage(conversationSid, body)→ send to grouplistParticipants(conversationSid)→ list group members
- Validates Twilio webhook signatures using
twilio.validateRequest()
capabilities: {
canSendMessage: true, canReceiveMessage: true, canReact: false,
canThread: false, canListChannels: false, canListUsers: false,
canSendMedia: true, canGroupChat: true,
}sendMessage→ picks Messages API (1:1) or Conversations API (group) based on scoperesolveUser→ looks up phone number inworkspace_member_identitiesformatOutboundText→ strips markdown formatting to plain text, truncates to 1600 charsnormalizeInbound→ handles both 1:1 webhook payloads and Conversations webhook payloads
- POST
/api/sms/webhook- receives Twilio inbound SMS (1:1) - POST
/api/sms/conversations/webhook- receives Twilio Conversations events (group) - Signature verification, deduplication (Redis
sms_event:{MessageSid}) - Looks up workspace from
Tophone number viaworkspace_messaging_connections - Sets
conversationScope: 'group'for Conversations events,'private'for 1:1 - Dispatches to
messaging-event-dispatcher - Returns empty TwiML (async agent response)
- POST
/api/sms/connect- stores Twilio credentials for workspace (admin-gated) - GET
/api/sms/status- check if SMS is configured - POST
/api/sms/link-user- link phone number to workspace member
Shared inbound message flow, used by SMS webhook (and eventually by Slack handlers too):
- Get platform adapter from registry
- Resolve workspace connection
- Resolve Obvious user from external user ID (via adapter)
- If user not found → send "connect your account" prompt
- Find or create Obvious thread (via
findByMessagingPlatform/ create +linkToMessagingPlatform) - Group chat ownership check — replaces the current Slack-only thread-owner gate:
// Look up the messaging thread link const link = await findMessagingThreadLink(thread.id, event.platform) if (link.conversationScope === 'private') { // Private: only thread owner can interact (existing Slack behavior) if (thread.userId !== resolvedUser.userId) return } // Group: any resolved workspace member can interact — no owner check
- Build message with sender context for group chats:
const prefix = event.conversationScope === 'group' ? `[${resolvedUser.userName || 'User'} via ${event.platform}]` : `[via ${event.platform}]` const messageWithContext = `${prefix}\n\n${event.text}`
- Start agent execution with
messagingContext(includesconversationScope)
| Platform | Private (1:1) | Group |
|---|---|---|
| Slack | DM threads | Channel threads — scope='group' when channel has >2 members |
| SMS | Messages API (single phone→phone) | Conversations API (multi-participant) |
| Discord (future) | DMs | Server channels |
| WhatsApp (future) | 1:1 chats | Group chats |
In a group conversation, multiple users send messages to the same Obvious thread. The agent sees messages prefixed with the sender's name, giving it context about who said what. Any group member who has linked their account can interact with the bot.
- Import
MessagingContextfrom messaging-platform types - Add
messagingContext?: MessagingContexttoAgentExecutionEventData(alongside existingslackContext)
- Add
messagingContext?: MessagingContexttoStartChatParams - Pass through to
AgentExecutionEventData
- Inngest function listening to
messaging/agent.response - Gets platform adapter, connection credentials
- Extracts final assistant message text
- Calls
adapter.sendMessage()andadapter.formatOutboundText() - Stores message link via
storeMessageLink()
- After existing
if (eventData.slackContext)block (~line 852), add:
if (eventData.messagingContext) {
await step.run('send-messaging-response', async () => {
await inngest.send({
name: 'messaging/agent.response',
data: { threadId, turnId, messagingContext: eventData.messagingContext },
})
})
}Operations:
get_connection_status- check if SMS/Twilio is configuredsend_message- send SMS to a phone number (E.164 format, max 1600 chars)create_group- create a group SMS conversation (Twilio Conversations API)add_to_group- add a phone number to an existing group conversationsend_group_message- send message to a group conversationlist_group_members- list participants in a group conversation
Register in tool registry, gate behind sms-messaging feature flag.
The current hard gate at line 159:
if (obviousThread.userId !== workspaceMember.userId) {
return // silently ignore
}Changes to check the thread's messaging link scope:
const link = await findMessagingThreadLink(obviousThread.id, 'slack')
if (link?.conversationScope === 'group') {
// Group thread: any linked workspace member can interact
} else if (obviousThread.userId !== workspaceMember.userId) {
return // Private: only owner can interact
}When creating a new thread from a channel @mention, determine scope:
- Slack channels →
conversationScope: 'group' - Slack DMs →
conversationScope: 'private'
This is derived from the channel type (channel vs DM) which Slack provides in the event payload.
- Keep Slack code paths intact - existing
slack-operations.tool.ts, event handlers,slack-agent-response.tsall stay. New code runs in parallel. - SMS uses two Twilio APIs - Messages API for 1:1, Conversations API for group. The adapter picks transparently based on
conversationScope. - One tool per platform (not one mega-tool) - platforms have fundamentally different capabilities. Slack has 12 operations; SMS has 6.
- Platform adapters handle text formatting -
formatOutboundText()on each adapter converts markdown to the platform's native format. - Connection credentials in JSONB - each platform stores different credentials. Encrypted using existing
token-encryption.service.ts. - Group chat via
conversationScope- stored onmessaging_thread_links, controls thread ownership behavior.'group'allows any linked workspace member to interact;'private'restricts to thread creator only. - Group messages are prefixed with sender name - so the agent knows who said what in multi-user conversations. e.g.
[Alice via sms]\n\nHey, can you check the quarterly numbers?
| File | Purpose |
|---|---|
apps/api/src/services/messaging-platform/types.ts |
Core types |
apps/api/src/services/messaging-platform/adapter.interface.ts |
Adapter contract |
apps/api/src/services/messaging-platform/registry.ts |
Platform registry |
apps/api/src/services/messaging-platform/event-dispatcher.ts |
Inbound dispatcher |
apps/api/src/services/messaging-platform/adapters/slack.adapter.ts |
Slack adapter |
apps/api/src/services/messaging-platform/adapters/sms.adapter.ts |
SMS adapter |
apps/api/src/services/messaging-platform/adapters/index.ts |
Registration |
apps/api/src/services/sms-integration/twilio-api.service.ts |
Twilio wrapper |
apps/api/src/services/sms-integration/twilio-signature.service.ts |
Webhook verify |
apps/api/src/services/sms-integration/types.ts |
Twilio types |
apps/api/src/services/messaging-link.service.ts |
Message link CRUD |
apps/api/src/routes/sms/webhook.route.ts |
Inbound SMS webhook |
apps/api/src/routes/sms/setup.route.ts |
SMS connection setup |
apps/api/src/inngest/messaging-agent-response.ts |
Generic outbound |
apps/api/src/agents/obvious-v2/tools/sms-operations.tool.ts |
Agent SMS tool |
| File | Change |
|---|---|
apps/api/src/db/schema.ts |
Add 3 new tables |
apps/api/src/services/thread.service.ts |
Add linkToMessagingPlatform, findByMessagingPlatform |
apps/api/src/agents/obvious-v2/types.ts |
Add messagingContext to AgentExecutionEventData |
apps/api/src/agents/obvious-v2/index.ts |
Add messagingContext to StartChatParams, pass through |
apps/api/src/inngest/obvious-agent-execution.ts |
Add messagingContext dispatch block |
apps/api/src/constants/messaging.ts |
Add MessagingPlatformId type, SMS constants |
apps/api/src/routes/slack/handlers/message.handler.ts |
Relax thread owner gate for group scope |
apps/api/src/routes/slack/handlers/app-mention.handler.ts |
Set conversationScope: 'group' for channel threads |
| File | Reuse |
|---|---|
apps/api/src/services/webhooks/provider-registry.ts |
Pattern for registry |
apps/api/src/services/webhooks/provider.interface.ts |
Pattern for adapter interface |
apps/api/src/services/slack-integration/slack-api.service.ts |
Wrapped by Slack adapter |
apps/api/src/services/slack-integration/slack-signature.service.ts |
Used by Slack adapter |
apps/api/src/services/token-encryption.service.ts |
Encrypt/decrypt credentials |
apps/api/src/utils/workspace-utils.ts |
resolveWorkspaceIdFromContext |
- Existing Slack still works: Run
bun obvious test --changedafter each commit. Existing Slack tests pass. - New tables migrate cleanly: Run
bun run db:generatethenbun run db:migrateto verify schema. - SMS 1:1 inbound: Test with Twilio webhook simulator or
curlagainst/api/sms/webhook. - SMS 1:1 outbound: Send a message via
sms-operationstoolsend_messageand verify Twilio receives it. - SMS group chat: Create group via
create_group→ add participants → send message → verify all receive it. - SMS group inbound: Multiple participants text into a Twilio Conversation → verify all messages route to same Obvious thread with sender prefixes.
- Slack group (channel) thread: User A @mentions bot in channel, User B replies in same thread → verify User B's message is processed (not ignored).
- Slack private (DM) thread: User A DMs bot, User B tries to continue → verify User B is still blocked.
- End-to-end: Text the Twilio number → verify agent starts → verify response SMS arrives.
- Typecheck:
bun obvious typecheck --changedpasses with noanytypes.