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
  • email
  • 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

Start building your AI team