Technical docs
Contacts and Channels
Overview
Support reference for how customer identity is linked across Omni, public webchat, API threads, Telegram, and internal chat.
Core model
- `Contact` is the stable person record.
- `ContactChannel` is the per-channel identity for that contact.
- `Conversation` always attaches to a `contact_channel_id`, not directly to `contact_id`.
- Active conversation membership is represented in `conversation_participants`, including `contact_channel`, `user`, and `ai_employee` participants.
- The first AI participant created for a conversation has role `primary_ai`. Additional AI teammates are recorded as `member`, and later upserts preserve an existing participant role instead of replacing it.
- Slack and Microsoft Teams processing add the AI teammate that handles or sends a live message as an active participant, so one conversation can include multiple AI teammates.
- Conversation authorization, AI-teammate filtering, and realtime recipient selection use active AI participants. A user can reach a conversation when they can access at least one active AI participant in it.
- `Message` rows now carry a nullable `channel` enum column, populated from the conversation's channel when the message is created across channel, conversation, webchat, internal-chat, and system-log processors.
Stored channel types
- The `ContactChannel` model currently defines:
- `api`
- `web_chat`
- `employee_chat`
- `telegram`
- `ms_teams`
- `slack`
- `custom`
- Current backend channel grouping treats these as external channels:
- `api`
- `web_chat`
- `telegram`
- `ms_teams`
- `slack`
- `custom`
- Current backend internal-channel grouping contains only:
- `employee_chat`
Identity key and profile fields
- Contact-channel uniqueness is enforced by:
- `organization_id + channel + external_id`
- Key `ContactChannel` fields used in current code:
- `contact_channel_id`
- `contact_id`
- `channel`
- `external_id`
- `auto_name`
- `first_name`
- `last_name`
- `phone`
- `email`
- `current_conversation_id`
- `auto_name` is generated when a channel record is first created.
- Phone and email are normalized on create/update.
Omni customer mapping
- Omni customer identity is derived from the backend `contact_channel` payload on the conversation.
- The current conversation DTO also includes `participants[]`, and the frontend now treats that participant list as the source of truth when resolving the primary contact channel for a thread.
- Display name resolution is:
- `first_name + last_name`
- otherwise `auto_name`
- otherwise `Unknown Customer`
- The current Omni customer summary uses `contact_channel` data for:
- name
- phone
- For group-capable conversations, the frontend currently picks the first active contact-channel participant as the primary customer summary until it supports a full roster UI.
Public webchat linkage
- Public webchat session identity is resolved in this order:
- valid `webchat-token`
- `contact.external_id` from the request
- generated UUID
- Once a public message flow resolves an external ID, `UpsertWebchatConversation` upserts a `web_chat` contact channel for:
- organization
- channel = `web_chat`
- that external ID
- Re-sending the same `contact.external_id` reuses the same `ContactChannel`.
- Supplying profile fields (`first_name`, `last_name`, `phone`, `email`) updates the existing `ContactChannel` instead of creating a second one.
- If a request includes both a valid `webchat-token` and an explicit `contact.external_id`, the backend now rejects the request when those two identities do not match.
Conversation linkage and reuse
- All conversations belong to one `contact_channel_id`.
- For public webchat:
- default behavior is single-conversation reuse per resolved `web_chat` contact channel and AI employee scope
- if `multi_conversations` is true:
- explicit `conversation_id` reuses that conversation
- no `conversation_id` creates a new conversation
- `GET /api/public/webchat/conversations` lists all conversations for a resolved `web_chat` contact channel.
- Public webchat conversation list and message reads now scope membership through active contact-channel participants, so group-capable conversation queries do not rely only on the legacy conversation-level `contact_channel_id` column.
`current_conversation_id` behavior
- `current_conversation_id` exists on `ContactChannel`.
- In the current codebase it is actively used and updated for internal chat conversation resolution.
- Internal chat resolution order is:
- explicit `create_new_conversation`
- explicit `conversation_id`
- `current_conversation_id`
- latest conversation for that contact channel
- Public webchat upsert does not currently use or update `current_conversation_id`.
- Omni queue loading also does not rely on `current_conversation_id`.
Internal chat external IDs
- Internal chat still uses the same `Contact` and `ContactChannel` system.
- Current internal chat uses the stored channel type `employee_chat`.
- There is no separate stored `ally_chat` contact-channel type in the current backend model.
Telegram linkage
- Telegram is now a first-class `ContactChannel.channel` value.
- AI teammate Telegram bots use employee-scoped bot credentials stored on the AI teammate record:
- `telegram_bot_id`
- `telegram_bot_token`
- Employee Telegram management routes are:
- `GET /api/organizations/{orgId}/employees/{employee_id}/telegram`
- `POST /api/organizations/{orgId}/employees/{employee_id}/telegram`
- `DELETE /api/organizations/{orgId}/employees/{employee_id}/telegram`
- `PUT /api/organizations/{orgId}/employees/{employee_id}/telegram/webhook`
- Telegram conversation external chat identity currently resolves as:
- private or non-topic chat: `String(message.chat.id)`
- topic message: `"{message.chat.id}:{message.message_thread_id}"`
- That means separate Telegram topics in the same group can map to separate Alloy conversation external IDs.
- Telegram conversation titles are auto-managed only for non-private chats:
- private chats keep `title = null`
- group and supergroup chats use the current Telegram chat title when available
- topic-aware titles append the topic name when the inbound Telegram payload includes that topic-creation context
- Existing Telegram group conversations are updated to the latest available auto title when new inbound messages provide title metadata.
- Incoming Telegram messages currently support:
- text
- photo
- document
- audio
- voice
- video
- video note
- animation
- sticker
- Chat attachment limits are deployment-configurable through `CHAT_MAX_ATTACHMENTS` (default `10`), `CHAT_MAX_ATTACHMENT_SIZE_MB` (default `10`), and `CHAT_MAX_TOTAL_ATTACHMENT_SIZE_MB` (default `30`).
- The Telegram webhook processes at most the configured attachment count per inbound message. Each attachment is capped at the per-file limit; the total accepted attachment payload is capped at the total limit per message. Oversized items are skipped and the rest of the message still continues.
- Incoming Telegram messages without text can still be accepted when at least one supported attachment survives validation.
- When attachments are rejected for exceeding the count, per-file, or total limits, the bot posts a warning into the channel: `Some attachments were not processed. You can attach up to ...`. Slack posts the warning into the thread when the inbound message was in a thread; Telegram sends it as a reply message; MS Teams sends it as a text message to the conversation.
- Telegram audio or voice attachments are transcribed with the AI teammate's explicit `stt_model` when configured, otherwise the system `stt_default_model`, before the message is sent to the agent. If transcription succeeds, the transcript is appended to the message content; if no usable STT model resolves or transcription fails, the agent receives a system note that the voice message could not be transcribed.
- For Telegram-triggered workflow runs, if the inbound message replies to a text message, the workflow input `message` is prefixed as `reply to [[original text]]: {new text}`.
- Workflow execution only forwards image attachments into workflow `inputData.attachments`; non-image Telegram files stay attached to the saved message but are not passed into the AI input payload.
- Generated AI and bot replies that carry attachments are delivered to Telegram as separate file messages: JPEG and PNG attachments are sent as photos, and every other supported attachment type is sent as a document.
- Each delivered attachment is moved to its own persisted message record in a transaction, so Omni and message history show one message per delivered file. File delivery targets the same chat and topic as the text reply.
- File delivery reads each attachment with the standard `10 MB` per-file cap. A file that Telegram rejects or that fails to prepare marks that file message `failed`; the text part of the reply is delivered independently.
- Generated replies up to `32,768` characters are first sent through Telegram rich Markdown delivery.
- If the rich text is longer than that limit or Telegram explicitly rejects its Markdown syntax, Alloy falls back to plain text split into messages of at most `4096` characters.
- Plain fallback prefers paragraph, newline, sentence, and word boundaries and avoids cutting Unicode surrogate pairs.
- An unknown rich-delivery outcome is not retried, which avoids duplicating a message that Telegram may already have accepted. Other explicit Telegram rejections are logged without a plain fallback.
- During AI reply generation for Telegram conversations, the bot sends Telegram `typing` chat actions while the workflow is running.
- Each seen Telegram private chat, group, or supergroup is registered in the AI teammate's channel-chat list. New rows are disabled until an Alloy operator enables them.
- When a disabled Telegram chat directly addresses the bot, Alloy sends: `This chat isn't enabled for this AI agent yet. Please ask your {brand} administrator to enable it in the agent's channel settings.` Private chats always count as directly addressed; groups receive the notice for an explicit bot mention or a reply to the bot.
- The disabled gate runs before attachment download, conversation/message persistence, and workflow creation. The channel-chat row is retained so an operator can enable it.
- If an addressed, enabled Telegram chat resolves an AI teammate that has reached its daily spending limit, Alloy does not create a workflow run and replies: `This AI agent has reached its daily cost limit and is temporarily unavailable. Please contact your organization administrator.`
- For system AI employees that are owned by a specific Alloy user, inbound Telegram bot messages are ignored unless the Telegram sender matches that owner's linked `telegramAuth.telegram_id`.
Microsoft Teams linkage
- Microsoft Teams is now a first-class `ContactChannel.channel` value with stored channel key `ms_teams`.
- Employee Microsoft Teams management routes are:
- `GET /api/organizations/{orgId}/employees/{employee_id}/msteams`
- `POST /api/organizations/{orgId}/employees/{employee_id}/msteams`
- `DELETE /api/organizations/{orgId}/employees/{employee_id}/msteams`
- `GET /api/organizations/{orgId}/employees/{employee_id}/msteams/chats`
- `PUT /api/organizations/{orgId}/employees/{employee_id}/msteams/chats/{chat_id}/status`
- `GET /api/organizations/{orgId}/employees/{employee_id}/msteams/package`
- The employee Teams info payload can expose a `messaging_endpoint` value even before the bot is connected, so users can register it in Azure Bot setup.
- Teams contact identity currently resolves from the inbound sender in this order:
- `activity.from.aadObjectId`
- `activity.from.id`
- Teams conversation title currently resolves by scope:
- personal chat: `activity.from.name`
- channel: `channelData.channel.name`, then team name, then conversation name fallback
- group chat: `activity.conversation.name` when present
- Teams conversations can store `ms_teams_meta` on the conversation for proactive replies:
- `ms_teams_service_url`
- `recipient_id`
- optional `addressed_to_bot` when the root message addressed the bot
- A personal chat, channel, or group appears in the `Microsoft Teams chats` list only after the bot has seen a message there.
- Teams channel/group keys use the conversation id with any `;messageid=...` thread suffix stripped, so replies in the same channel share one allowlist row.
- Newly discovered Teams chats are created with status `disabled` and must be enabled before the AI can operate there.
- Shared Teams conversations also require the bot to be addressed before AI runs:
- personal chats always count as addressed
- channel and group messages require a bot mention, or a reply inside a thread whose root message addressed the bot
- When a Teams chat is disabled, a directly addressed message receives: `This chat isn't enabled for this AI agent yet. Please ask your {brand} administrator to enable it in the agent's channel settings.` Personal chats always receive the notice; channels and group chats receive it only for an explicit bot mention.
- The disabled gate retains the channel-chat registration but runs before attachment download, conversation/message persistence, and workflow creation.
- If an addressed, enabled Teams chat resolves an AI teammate that has reached its daily spending limit, Alloy does not create a workflow run and replies: `This AI agent has reached its daily cost limit and is temporarily unavailable. Please contact your organization administrator.`
- Teams attachments are capped at `10` files, `10 MB` per file, and `30 MB` total per inbound message.
- Teams audio attachments are transcribed with the AI teammate's explicit `stt_model` when configured, otherwise the system `stt_default_model`, before the message is saved and sent to the agent. If transcription succeeds, the transcript is appended to the message content; if no usable STT model resolves or transcription fails, the agent receives a system note that the voice message could not be transcribed.
- During AI reply generation for Teams conversations, the bot sends a best-effort Bot Framework typing activity while the workflow is running.
- Teams workflow execution forwards image attachments into workflow input attachments; non-image Teams files stay on the saved message but are not forwarded into the AI input payload.
- Generated Teams replies can deliver attachments even when the reply text is empty.
- PNG, JPEG, and GIF files are sent inline while the estimated activity remains within the `90 KB` Teams activity budget. Other files and images that do not fit inline follow the scope-specific file flow.
- In personal chats, each non-inline file is offered through a Teams file-consent card. Accepting uploads and posts the file; declining sends `File upload canceled: {name}`.
- In Teams channels, non-inline files are uploaded to the channel's SharePoint-backed Files folder through Microsoft Graph. The reply adds a `Files:` list with links to the uploaded files.
- Generated-file delivery is not enabled for Teams group chats. A group-chat reply with text can still send the text without its files.
- If channel delivery preparation or upload fails, Alloy marks the generated message as `failed`. Personal file-consent uploads track `pending`, `uploading`, `uploaded`, `declined`, and `failed` states separately.
Slack linkage
- Slack is a first-class `ContactChannel.channel` value with stored channel key `slack`.
- Alloy supports two Slack connection models:
- one organization-wide Slack app that routes each linked Slack user to that user's Personal Ally
- dedicated employee-scoped Slack apps for non-personal AI teammates
Organization-wide Slack app and Personal Ally
- An organization admin connects one Slack workspace from `/org-settings/organization` through Slack OAuth. Personal Ally does not store separate Slack bot credentials.
- Organization connection routes are:
- `GET /api/organizations/{orgId}/slack`
- `POST /api/organizations/{orgId}/slack/oauth/start`
- `POST /api/slack/oauth/callback`
- `DELETE /api/organizations/{orgId}/slack`
- Reading connection state requires organization access. Starting OAuth and disconnecting require organization-admin access. OAuth completion validates the signed state, the authenticated user, and that user's admin or owner membership.
- The public app receives events at `POST /webhooks/slack/events`. The webhook verifies the Slack signature and public app ID, then resolves the active organization connection by Slack workspace ID.
- Inbound messages are processed only for the workspace connected to the organization. Invalid stored connection metadata or an invalid bot token marks the connection `invalid`, which makes the Organization settings UI offer `Reconnect`.
- A Slack identity must be linked to an Alloy organization user before the public app starts an AI run. Link-code messages are handled by the existing channel-linking flow; other unlinked messages do not run Personal Ally.
- After linking, the public app resolves the Alloy user's own Personal Ally. Direct messages address Ally automatically. Channel and group messages require a bot mention, or a reply in a thread whose root was handled by a Personal Ally.
- Public-app conversations use the Slack channel ID as the conversation external ID and the Slack sender user ID as the contact external ID.
- The public app accepts normal messages and `file_share`; it ignores bot messages and other message subtypes.
- Audio attachments are transcribed with the resolved Personal Ally's explicit `stt_model` when configured, otherwise the system `stt_default_model`. Images are forwarded into workflow input; other accepted files remain on the saved message but are not forwarded as workflow input attachments.
- During processing, the public app uses saved Slack message and thread timestamps for replies and best-effort typing status when those timestamps are available.
Dedicated AI teammate Slack apps
- Non-personal AI teammate Slack bots use employee-scoped credentials. Operators connect a bot by entering the Slack Bot User OAuth Token and Signing Secret in the teammate's Slack tab.
- Employee Slack management routes are:
- `GET /api/organizations/{orgId}/employees/{employee_id}/slack`
- `POST /api/organizations/{orgId}/employees/{employee_id}/slack`
- `DELETE /api/organizations/{orgId}/employees/{employee_id}/slack`
- `GET /api/organizations/{orgId}/employees/{employee_id}/slack/manifest`
- The Slack app manifest route returns the JSON manifest that pre-fills the app name, required bot scopes, subscribed message events, and Events Request URL for that AI teammate. The Slack tab fetches it on demand for `Copy manifest`.
- Slack message subtype `file_share` is accepted so user file and voice messages can be processed; other message subtypes such as edits, joins, and bot messages are skipped.
- A Slack DM or channel appears in the `Slack chats` UI only after the dedicated bot has seen a message there.
- Newly discovered dedicated-app Slack chats are created disabled by default and must be enabled before the AI can operate there.
- When a disabled dedicated-app Slack chat directly addresses the bot, Alloy replies: `This chat isn't enabled for this AI agent yet. Please ask your {brand} administrator to enable it in the agent's channel settings.` DMs always count as directly addressed; shared conversations receive the notice only for an explicit bot mention. The notice uses the triggering Slack thread when one is available.
- The disabled gate retains the channel-chat row but runs before attachment download, conversation/message persistence, and workflow creation.
- If an addressed, enabled dedicated-app Slack chat resolves an AI teammate that has reached its daily spending limit, Alloy does not create a workflow run and replies: `This AI agent has reached its daily cost limit and is temporarily unavailable. Please contact your organization administrator.` The reply uses the same thread target as the normal addressed-message path.
- Dedicated-app Slack conversation external chat identity uses the Slack channel ID. Contact identity uses the inbound Slack sender user ID.
- Dedicated-app Slack DM replies are allowed when the DM chat is enabled. In channels and groups, the bot must be mentioned or the user must continue in a thread whose root message already addressed the bot.
- Slack channel and group replies are sent in threads; a top-level message uses its own Slack timestamp as the thread root.
- Slack attachments are capped at `10` files, `10 MB` per file, and `30 MB` total per inbound message.
- On the first addressed reply in a previously unknown channel thread, Alloy imports earlier human replies and downloadable attachments from Slack before starting the AI run. Imported rows keep their source timestamps and authors, do not change the conversation preview, and do not publish new-message events. Root messages and direct-message threads are not imported, and known threads are not imported again. If complete history cannot be loaded, the run continues and Slack receives an in-thread warning that context may be incomplete.
- Once a channel thread is known, later human replies in it are persisted as passive history even when they do not address the bot, come from an unlinked user, or mention someone else. Passive history does not start an AI run unless the normal addressing and linking rules pass. Duplicate Slack deliveries are deduplicated within the conversation.
- Slack audio attachments are transcribed with the AI teammate's explicit `stt_model` when configured, otherwise the system `stt_default_model`, before the message is saved and sent to the agent. If transcription succeeds, the transcript is appended to the message content; if no usable STT model resolves or transcription fails, the agent receives a system note that the voice message could not be transcribed.
- During AI reply generation for Slack conversations, the bot sets a best-effort Slack assistant thread typing status when the thread timestamp is available.
- Slack caps the number of blocks one message can hold. Long AI and bot replies are split into multiple Slack messages at top-level Markdown boundaries, and each extra chunk is persisted as its own message record.
- If Slack rejects a chunk because its block expansion exceeds the cap, the chunk is re-split with a halved budget and retried in place.
- When a reply includes files, the leading markdown chunks are posted first and only the final chunk rides along as the caption with the files.
Attachment storage encryption state
- Attachment records now store `is_encrypted`.
- When backend S3 SSE-C is enabled through `HETZNER_S3_SSE_C_KEY`, new attachment uploads are written as encrypted S3 objects and the attachment row is marked `is_encrypted = true`.
- Shared attachment download routes use the stored `is_encrypted` flag to decide whether the S3 read must include SSE-C headers.
- Existing plaintext attachments remain readable until they are explicitly backfilled and marked encrypted.
Channel linking — connecting external accounts to organization users
- Organization users can link their external channel accounts (starting with Slack) to their Alloy user through a temporary link code flow.
- The link page is at `/organizations/{orgId}/channels/{channel}/link/` on the frontend. It requires authentication and generates a time-limited `LINK-CODE-XXXX-XXXX` code.
- In Slack, users can also enter `/alloy link-code` in a DM with the bot to receive a link code.
- Backend `ChannelLinkService` validates the code, links the channel identity to the organization user, and sends a confirmation message through the channel.
- Code creation and usage are rate-limited. Expired codes are cleaned up by a cron job.
- On successful Slack link, the contact channel profile (name, avatar) is synced from the Slack user profile.
- The `ContactChannel` model includes a `meta` JSONB field for channel-specific metadata.
- The current AI prompt context now includes `external_id` from the contact channel record so AI teammates can reference the external identifier.
- Backend API routes for channel link codes: `GET /api/organizations/{orgId}/channels/{channel}/link-codes` and `POST /api/organizations/{orgId}/channels/{channel}/link-codes`.
Custom channel linkage
- Custom chat integrations are documented in `references/custom-channel-integration.md`.
- Employee message API routes are:
- `POST /api/private/employee/{employee_id}/messages`
- `GET /api/private/employee/{employee_id}/messages`
- External integrations should send a stable `channel` value for the chat system, plus `conversation.id` and `contact.id` for saved chats.
- Reusing the same `channel` and `conversation.id` continues the same saved chat.
- Reusing the same `channel` and `contact.id` keeps messages associated with the same external sender.
- `conversation` and `contact` are all-or-nothing on `POST`. When both are supplied, Alloy saves the incoming message and the chat can be listed later. When both are omitted, the same route handles a one-off request without saving chat history.
Search behavior
- `ConversationService.searchByContactChannel(...)` is scoped to one contact channel at a time.
- Search sources are:
- conversation title via `ILIKE`
- message content via full-text search
- Title hits are returned before message hits.
- Search can also be scoped by `aiEmployeeId`.
Support checks
- If a public widget user unexpectedly forks into a second identity:
- inspect `channel + external_id`
- inspect whether the widget changed `contact.external_id`
- inspect whether the stored `webchat-token` belongs to a different external ID
- inspect whether the request mixed a valid `webchat-token` with a different explicit `contact.external_id`
- If an internal chat thread reopens the wrong conversation:
- inspect `current_conversation_id`
- inspect `contact_channel_id`
- inspect the generated external ID format for that channel
- for Ally, confirm which per-user Ally employee was resolved
- If Omni shows the wrong customer name:
- inspect `participants[]`
- inspect `first_name`
- inspect `last_name`
- inspect `auto_name`
- If a Telegram customer says a file was sent but the workflow did not use it:
- inspect the inbound message attachments
- confirm whether the file was an image or a non-image attachment
- confirm whether the attachment exceeded `10 MB` or the message exceeded the `30 MB` total attachment cap
- remember that only image attachments are forwarded into workflow input data
- If a Telegram customer says a generated file from an AI reply was not delivered:
- inspect the file message status, which becomes `failed` when Telegram rejects the file or delivery preparation fails
- confirm the file stayed within the `10 MB` per-file cap
- confirm the bot can send documents or photos to that chat and topic
- If a Telegram topic or group message lands in the wrong conversation:
- inspect the resolved external chat ID
- inspect whether the inbound message carried `message_thread_id`
- If a Telegram group thread shows an outdated or missing title in Omni:
- inspect the latest inbound Telegram payload for `chat.title`
- inspect whether topic context was present in the inbound payload
- remember that private Telegram chats intentionally keep no conversation title
- If a Telegram AI reply seems to reference an earlier quoted message unexpectedly:
- inspect whether the customer used Telegram reply-to on a text message
- inspect the workflow input `message` payload for the injected `reply to [[...]]:` prefix
- If a system employee Telegram bot ignores a message unexpectedly:
- inspect whether the AI employee is a system employee with `owned_by_user_id`
- inspect the owner's linked Telegram account ID versus the inbound sender Telegram ID
- If a personal Teams contact wrote to the bot but no AI reply was sent:
- inspect whether the contact was approved for that AI teammate
- inspect the Teams contact list under the AI teammate's `MS Teams` tab
- inspect whether the organization had available tokens
- inspect whether automation was disabled on the conversation
- If a Teams channel or group wrote to the bot but no AI reply was sent:
- inspect whether the channel/group row exists and is `enabled`
- inspect whether the message mentioned the bot or was in a thread whose root addressed the bot
- inspect whether the organization had available tokens
- inspect whether automation was disabled on the conversation
- If Teams messages are threading into the wrong contact:
- inspect the stored `channel + external_id`
- inspect whether the webhook used `activity.from.aadObjectId` or fell back to `activity.from.id`
- If a Slack DM or channel wrote to the bot but no AI reply was sent:
- inspect whether the Slack chat row exists and is `enabled`
- inspect whether the channel message mentioned the bot or was in a thread whose root addressed the bot
- inspect whether the organization had available tokens
- inspect whether automation was disabled on the conversation
- If a Telegram, Slack, or Teams voice message was not understood:
- inspect whether the AI teammate has an STT-capable `stt_model` or the system `stt_default_model` is configured
- inspect whether the STT model provider is connected for the organization
- inspect whether the saved message content contains a transcription or a system note about failed transcription