Skip to content

Instantly share code, notes, and snippets.

@matthew-gerstman
Last active February 10, 2026 15:30
Show Gist options
  • Select an option

  • Save matthew-gerstman/08942823a959003235988ebe391b008f to your computer and use it in GitHub Desktop.

Select an option

Save matthew-gerstman/08942823a959003235988ebe391b008f to your computer and use it in GitHub Desktop.
Abstract messaging: Slack → Multi-Platform (SMS/Twilio first)

Abstract Messaging: Slack → Multi-Platform (SMS/Twilio first) with Group Chat

Commits

  1. feat: add messaging platform types, adapter interface, and registry
  2. feat: add database tables for generic messaging connections and links
  3. feat: add thread/message service methods for generic messaging links
  4. refactor: create Slack messaging platform adapter wrapping existing code
  5. feat: add Twilio SMS/Conversations service wrapper and adapter
  6. feat: add SMS inbound webhook route and connection setup
  7. feat: add messaging event dispatcher with group chat support
  8. feat: add MessagingContext to agent execution types
  9. feat: add generic messaging agent response Inngest function
  10. feat: add sms-operations agent tool
  11. refactor: relax thread ownership for group messaging threads
  12. test: add tests for messaging platform adapters, dispatcher, and group chat

Architecture Overview

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.

Abstraction Layers

[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

Phase 1: Core Types and Registry

New: apps/api/src/services/messaging-platform/types.ts

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
}

New: apps/api/src/services/messaging-platform/adapter.interface.ts

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
}

New: apps/api/src/services/messaging-platform/registry.ts

Follow pattern from apps/api/src/services/webhooks/provider-registry.ts:

  • registerPlatform(adapter), getPlatform(id), getPlatformOrThrow(id), getAllPlatforms()

Phase 2: Database Schema

New table: workspace_messaging_connections

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

New table: messaging_thread_links

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

New table: workspace_member_identities

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

Thread/Message Service Additions

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)

Phase 3: Slack Adapter

New: apps/api/src/services/messaging-platform/adapters/slack.adapter.ts

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 → creates SlackApiService from connection credentials, calls postMessage, converts markdown via slackifyMarkdown
  • verifyWebhook → delegates to existing verifySlackSignature
  • resolveUser → queries workspaceMembers.slackUserId (existing column)
  • formatOutboundTextslackifyMarkdown()

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.


Phase 4: SMS/Twilio Adapter

Twilio API Strategy: Messages API + Conversations API

  • 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

New: apps/api/src/services/sms-integration/twilio-api.service.ts

  • Wraps both Twilio Messages API and Conversations API (twilio npm package)
  • sendSms(to, body) → 1:1 message, returns { sid }
  • createConversation(friendlyName) → group conversation, returns { conversationSid }
  • addParticipant(conversationSid, phoneNumber) → add member to group
  • sendConversationMessage(conversationSid, body) → send to group
  • listParticipants(conversationSid) → list group members

New: apps/api/src/services/sms-integration/twilio-signature.service.ts

  • Validates Twilio webhook signatures using twilio.validateRequest()

New: apps/api/src/services/messaging-platform/adapters/sms.adapter.ts

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 scope
  • resolveUser → looks up phone number in workspace_member_identities
  • formatOutboundText → strips markdown formatting to plain text, truncates to 1600 chars
  • normalizeInbound → handles both 1:1 webhook payloads and Conversations webhook payloads

New: apps/api/src/routes/sms/webhook.route.ts

  • 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 To phone number via workspace_messaging_connections
  • Sets conversationScope: 'group' for Conversations events, 'private' for 1:1
  • Dispatches to messaging-event-dispatcher
  • Returns empty TwiML (async agent response)

New: apps/api/src/routes/sms/setup.route.ts

  • 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

Phase 5: Messaging Event Dispatcher (with Group Chat)

New: apps/api/src/services/messaging-platform/event-dispatcher.ts

Shared inbound message flow, used by SMS webhook (and eventually by Slack handlers too):

  1. Get platform adapter from registry
  2. Resolve workspace connection
  3. Resolve Obvious user from external user ID (via adapter)
    • If user not found → send "connect your account" prompt
  4. Find or create Obvious thread (via findByMessagingPlatform / create + linkToMessagingPlatform)
  5. 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
  6. 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}`
  7. Start agent execution with messagingContext (includes conversationScope)

How Group Chat Works Per Platform

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

Key Behavior: Group threads are shared

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.


Phase 6: Agent Execution Abstraction

Modify: apps/api/src/agents/obvious-v2/types.ts

  • Import MessagingContext from messaging-platform types
  • Add messagingContext?: MessagingContext to AgentExecutionEventData (alongside existing slackContext)

Modify: apps/api/src/agents/obvious-v2/index.ts

  • Add messagingContext?: MessagingContext to StartChatParams
  • Pass through to AgentExecutionEventData

New: apps/api/src/inngest/messaging-agent-response.ts

  • Inngest function listening to messaging/agent.response
  • Gets platform adapter, connection credentials
  • Extracts final assistant message text
  • Calls adapter.sendMessage() and adapter.formatOutboundText()
  • Stores message link via storeMessageLink()

Modify: apps/api/src/inngest/obvious-agent-execution.ts

  • 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 },
    })
  })
}

Phase 7: SMS Agent Tool

New: apps/api/src/agents/obvious-v2/tools/sms-operations.tool.ts

Operations:

  • get_connection_status - check if SMS/Twilio is configured
  • send_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 conversation
  • send_group_message - send message to a group conversation
  • list_group_members - list participants in a group conversation

Register in tool registry, gate behind sms-messaging feature flag.


Phase 8: Relax Thread Ownership for Group Messaging

Modify: apps/api/src/routes/slack/handlers/message.handler.ts

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
}

Modify: apps/api/src/routes/slack/handlers/app-mention.handler.ts

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.


Key Design Decisions

  1. Keep Slack code paths intact - existing slack-operations.tool.ts, event handlers, slack-agent-response.ts all stay. New code runs in parallel.
  2. SMS uses two Twilio APIs - Messages API for 1:1, Conversations API for group. The adapter picks transparently based on conversationScope.
  3. One tool per platform (not one mega-tool) - platforms have fundamentally different capabilities. Slack has 12 operations; SMS has 6.
  4. Platform adapters handle text formatting - formatOutboundText() on each adapter converts markdown to the platform's native format.
  5. Connection credentials in JSONB - each platform stores different credentials. Encrypted using existing token-encryption.service.ts.
  6. Group chat via conversationScope - stored on messaging_thread_links, controls thread ownership behavior. 'group' allows any linked workspace member to interact; 'private' restricts to thread creator only.
  7. 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?

Files to Create

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

Files to Modify

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

Existing Code to Reuse

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

Verification

  1. Existing Slack still works: Run bun obvious test --changed after each commit. Existing Slack tests pass.
  2. New tables migrate cleanly: Run bun run db:generate then bun run db:migrate to verify schema.
  3. SMS 1:1 inbound: Test with Twilio webhook simulator or curl against /api/sms/webhook.
  4. SMS 1:1 outbound: Send a message via sms-operations tool send_message and verify Twilio receives it.
  5. SMS group chat: Create group via create_group → add participants → send message → verify all receive it.
  6. SMS group inbound: Multiple participants text into a Twilio Conversation → verify all messages route to same Obvious thread with sender prefixes.
  7. 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).
  8. Slack private (DM) thread: User A DMs bot, User B tries to continue → verify User B is still blocked.
  9. End-to-end: Text the Twilio number → verify agent starts → verify response SMS arrives.
  10. Typecheck: bun obvious typecheck --changed passes with no any types.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment