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`.
- `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 `stt_model` before the message is sent to the agent. If transcription succeeds, the transcript is appended to the message content; if it 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 `stt_model` before the message is saved and sent to the agent. If transcription succeeds, the transcript is appended to the message content; if it 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 `stt_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.
- Slack audio attachments are transcribed with the AI teammate `stt_model` before the message is saved and sent to the agent. If transcription succeeds, the transcript is appended to the message content; if it 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`
- 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