Workspace/Lesson workspace
Loading progress
Acting & collaborating40 min

Build channel adapters that preserve identity and intent

Lesson 1 of 3
How to study this lesson

Read one section, trace the worked example, and try the knowledge check. Bookmark saves a shortcut; notes appear in My notebook. Mark lesson complete is your own assessment and does not mark its lab passed.

By the end, you can

  • Normalize platform events without erasing channel semantics.
  • Scope identities, conversations, and capabilities.
  • Separate inbound delivery from authorized outbound actions.

One agent core can serve several channels

A channel adapter translates platform-specific events into the agent's internal message contract and translates proposed responses back into supported platform operations. The core should not need a separate reasoning loop for Slack, Discord, and WhatsApp. A useful internal envelope includes platform, tenant, conversation, sender, message identifier, event identifier, event time, text, attachments, and reply context. Preserve the native identifiers because they are essential for deduplication, audit, and correct routing.

Normalization should make shared behavior explicit without pretending every channel is identical. A group conversation, a direct message, and an interactive command have different expectations. An edited message is not necessarily a new user request. A delivery receipt is not user text. Classify event kind before deciding whether the agent should run, and keep unrecognized event types observable instead of feeding their raw JSON into the model as if they were a conversation.

Delivery mechanisms differ

Slack's Events API delivers subscribed events and expects prompt acknowledgment. Discord interactions support commands and component interactions, with an initial response deadline of three seconds and follow-up tokens valid for fifteen minutes. WhatsApp Cloud API uses webhooks for inbound events and Graph API for sending messages. These are different adapter obligations, even when the eventual user text is identical. [Slack Events API](https://docs.slack.dev/apis/events-api/), [Discord interaction documentation](https://docs.discord.com/developers/interactions/receiving-and-responding), [Meta WhatsApp platform documentation](https://developers.facebook.com/documentation/business-messaging/whatsapp/about-the-platform).

Design a capability record for each installed channel: supports edits, supports threads, supports rich components, supports audio, and current delivery constraints. Populate it from the verified integration version rather than a permanent assumption. A response planner can then choose a supported presentation while the adapter retains responsibility for native request shapes and transport errors.

Identity is a namespaced relationship

A sender identifier is meaningful within its platform and tenant. User 42 in workspace A is not necessarily the same person as user 42 in workspace B, and a matching display name across platforms is not proof of identity. Use a composite principal such as platform, tenant, and sender ID. If cross-channel continuity is required, establish an explicit account-linking relationship through an authenticated process and record who authorized it.

Conversation identity also needs scope. A Slack thread, a Discord channel, and a WhatsApp conversation should not share memory merely because their last messages have similar text. Store channel context separately from the user's long-term preferences, and apply access rules before retrieving prior artifacts. This prevents a convenient unified inbox from accidentally turning into a cross-workspace disclosure mechanism. The code demonstrates namespaced keys using simplified local envelopes, not native platform payloads.

Render intent through the channel contract

The agent core might propose a response containing a summary, a small table, and three suggested actions. An adapter can render rich components where supported and a clear text fallback elsewhere. Preserve the underlying action identifier across presentations so clicking a button and replying with a command can refer to the same proposal. Do not put arbitrary executable instructions inside component labels or trust a client-supplied action payload without server validation.

Outbound delivery is a separate side effect. Receiving a message authorizes only the work implied by that request and the integration's established scope; it does not authorize broadcasting to unrelated channels. Bind the destination, content, and reply context to the proposed response. For multi-part output, track logical response identity and part numbers so a retry can avoid sending duplicate fragments or replying in the wrong thread.

Test the adapter as a semantic boundary

Fixture tests should include duplicate deliveries, message edits, empty text with an attachment, unknown event kinds, and two tenants with colliding native IDs. Assert the resulting envelope and routing behavior rather than testing only whether JSON parses. For outbound rendering, check that the meaning remains intact when rich UI is unavailable and that unsupported attachments produce a useful explanation or alternative.

Keep transport acknowledgment, agent progress, and final response status distinct in the user experience. An accepted event can still be waiting in a queue; an agent can complete while outbound delivery is retrying. Expose those states internally so support can diagnose the right component. A well-designed adapter absorbs platform differences while preserving the facts needed to reason about identity, ordering, and delivery. That boundary becomes even more important when a real-time voice channel joins the same agent core.

Work through the code

This teaching simulation uses an internal normalized envelope, not Slack, Discord, or WhatsApp wire payloads. It shows why platform and tenant belong in identity keys. Add a verified account-link table separately if cross-channel continuity is desired.

m17_lesson1.py
python
events = [
    {'platform': 'slack', 'tenant': 'A', 'sender': '42',
     'conversation': 'support', 'event_id': 'e1', 'text': 'status'},
    {'platform': 'discord', 'tenant': 'A', 'sender': '42',
     'conversation': 'support', 'event_id': 'e1', 'text': 'status'},
    {'platform': 'slack', 'tenant': 'B', 'sender': '42',
     'conversation': 'support', 'event_id': 'e1', 'text': 'status'},
]

def principal(event):
    return event['platform'], event['tenant'], event['sender']

def conversation_key(event):
    return event['platform'], event['tenant'], event['conversation']

principals = {principal(event) for event in events}
conversations = {conversation_key(event) for event in events}
print('distinct principals:', len(principals))
print('distinct conversations:', len(conversations))
for event in events:
    print('/'.join(principal(event)))
EXPECTED / ILLUSTRATIVE OUTPUT
distinct principals: 3
distinct conversations: 3
slack/A/42
discord/A/42
slack/B/42

Run Python snippets locally with the prerequisites named above. The module coding lab runs directly in your browser.

Pause and reason

A Slack user and Discord user share a display name and ask to resume the same private task. What must the system establish before reusing private memory?

Check your understanding

Which event should usually avoid starting a new reasoning run by itself?

Your notes

Explain the mechanism in your own words. Add a failure you want to test.

Saved notes appear in your notebook

Go deeper with primary sources

Practice this module