ADR-0064: Per-channel rendering of typed messages and normalised answers
| Status | Accepted; the genui joining rule (a chunk id seen again replaces its element) is superseded by ADR-0072 |
| Date | 2026-10-14 |
| Issue | #68 |
| Pull request | (this PR) |
| Related | ADR-0036, ADR-0039, ADR-0062 |
Context and problem statement
Since ADR-0062 a message activity can carry a typed
Protocol v1 message (message_type + payload): buttons, quick-reply, card, carousel, media,
interrupt, genui chunks and so on. End-user channels (WhatsApp, Telegram, Slack, email, SMS, ...) only
received its plain text fallback: an agent's buttons became "Track | Pricing" on WhatsApp, and there was no
way for the user to answer them other than typing. #68 asks
for the per-channel downgrade matrix of
messages.md section 5 and for answers that reach the
agent in one shape whatever the channel (section 6).
Two hard constraints shape the answer path. Telegram's callback_data is at most 64 bytes, so a button cannot
carry an arbitrary action value (interrupt values are JSON objects). And a numeric reply ("2") on SMS has no
reference to the message it answers.
Decision drivers
- One answer shape for agents and bots, whatever the channel:
text,metadata.action,reply_to_id. - No new storage: the offering activity already holds the actions losslessly.
- Reuse what the adapters already render (legacy attachments, card buttons, Block Kit) instead of a second rendering path per adapter.
- Every channel gets something usable, even when its provider has no buttons.
Considered options
- Opaque answer keys naming the stored activity: every native button carries
a:<activity id>:<index>, resolved by loading the activity; numeric replies match the latest actionable message. - A server-side answer table keyed by a short random token per button.
- Action values in the button payloads, as the legacy card buttons do.
Pros and cons of the options
Option 1
- Good, because the key is at most 41 bytes, fits every provider (Telegram 64, WhatsApp ids 256 / 200, Slack values 2000) and needs no new table or expiry.
- Good, because the value, the full label and non-string values come from the stored payload, so truncated labels and JSON values survive.
- Bad, because the key reveals the activity id to the provider; it is checked against the conversation, so it cannot be used to answer another conversation's message.
Option 2
- Good, because keys are shorter and opaque.
- Bad, because it adds a table, a write per rendered button and a retention policy for data the activity already holds.
Option 3
- Good, because it needs no lookup.
- Bad, because values over 64 bytes and non-string values cannot travel on Telegram, and truncated labels lose the full label.
Decision
Chosen option: "Opaque answer keys naming the stored activity".
- Adapters declare what they render natively in
capabilities/0(message_types:). On end-user channels (adapters that listactivity_types)Converger.Activities.Downgradedecides per typed message:tool_call: only withshow_tool_traces(unchanged);genui: nothing until the stream'sdonechunk, then one text message with the latest content of eachtextchunk of the stream (a chunk id seen again replaces its element), joined by blank lines;uiandeventchunks are never sent;interruptwith atoolor anevent: skipped (the client runtime answers those);- a declared type: delivered natively. Media (
image,file,video,audio),locationandcontactare handed to the adapter as the legacy attachment it already renders (contentUrl,application/vnd.converger.location,.contacts); actionable types keepmessage_typeandpayload; - anything else: a plain
messagewith the text rendering ofConverger.Channels.RichContent: the prompt, the answerable actions as a numbered list (1. Track an order), links aslabel: url, cards as blocks, media as caption and link.
Converger.Channels.RichContentis the one place that reads typed payloads for channels: the flat, indexed action list (indexfor answer keys,numberfor numbered lists; links have no number), prompts, cards, text and HTML rendering, attachments, answer keys and label truncation.- Native renderers (WhatsApp Cloud and Infobip via
Converger.Channels.WhatsApp.Outbound, Telegram, Slack Block Kit, email) put the answer keya:<activity id>:<index>in the button id, row id,callback_dataor button value. Inbound parsers that see a key add"answer" => %{"ref", "source", "label"}to the message. Converger.Inbound.Answersruns on every inbound message before it becomes an activity: a key that names an answerable action of an activity of the same conversation becomestext(the action's stringvalue, else itslabel),metadata.action = {label, value, source}andreply_to_id; an unresolvable key keeps the provider's label as text. A numeric reply on a channel that does not forward the canonical activity picks the numbered action of the most recent actionable message of the conversation sent by someone else, at most 24 hours old; otherwise it stays plain text. Messages that already carrymetadata.action(WebSocket clients) are left as they are.- Interrupt answers keep
reply_to_idon the interrupt activity; converting them toresumefor the bot is a separate step of the bot relay.
Consequences
Positive
- An agent sends one
buttonsmessage; the web widget shows chips, WhatsApp interactive buttons, Telegram an inline keyboard, Slack buttons, email and SMS a numbered list, and every answer arrives in the same shape. - No migration and no new state: answers resolve from the stored payload.
- Telegram also gained
sendVideo,sendAudio/sendVoice,sendLocationandsendContactfor legacy attachments.
Negative and trade-offs
- A numeric reply that was meant as a number ("2" as a quantity) within 24 hours of an actionable message is
read as an answer; the agent still sees
metadata.action.source = "numeric"and can tell. - A key resolves only while the offering activity exists and is in the same conversation: after retention removed it, or once a new conversation started, a tap arrives as plain text (the provider's label).
- Messenger, Instagram, Viber, Discord and Teams render actions as numbered text, not their native quick replies or buttons, and SMS (Twilio, Vonage) sends media as links in the text.
- GenUI streams cost one query per
donechunk for each end-user channel planned.
Follow-ups
- Native quick replies / buttons on Messenger, Instagram, Viber, Discord and Teams (their
ChatContent.buttonspath can carry answer keys). - Interrupt answers forwarded to the bot as
resume(bot relay, #68). - Email reply links to a Converger endpoint (messages.md section 5, opt-in).
Implementation
lib/converger/channels/rich_content.ex: shared reading and rendering.lib/converger/activities/downgrade.ex: the per-type plan for end-user channels.lib/converger/inbound/answers.ex, called fromConverger.Inbound: answer normalisation;Converger.Activities.recent_typed_messages/5,get_conversation_activity/2,genui_stream_payloads/2.lib/converger/channels/whatsapp/outbound.ex(both WhatsApp adapters),adapters/telegram.ex,adapters/slack/blocks.ex,adapters/slack/events.ex,email/outbound.ex, and themessage_types:entries of every end-user adapter.- Tests:
test/converger/channels/rich_content_test.exs,test/converger/inbound/answers_test.exs(keys, numeric matching and the 24-hour window),test/converger/activities/typed_message_test.exs(downgrade and GenUI), and the adapter tests of WhatsApp, Telegram, Slack and email.
Links
- Rich message vocabulary, sections 5 and 6
- Activities
- WhatsApp, Telegram, Slack, email, SMS