| name | channel-adapter-pattern |
| description | Use when an agent's response/formatting logic is (or will be) reachable from more than one delivery channel (chat, SMS, email, push, web). Triggers — before adding a second channel to a single-channel agent, before writing/editing a shared respond()/format_reply()/persona-selection helper, or when a multi-channel agent's voice output sounds like it was written for text (spelled digits, markdown symbols read aloud). |
Channel-Adapter Pattern
Overview
Generate the reply in ONE canonical form; let a thin per-channel adapter at the edge shape it. A shared respond() helper that can't see which channel it's rendering for silently reuses whichever channel was coded first — so a voice channel inherits a chat channel's markdown and digits, and reads *asterisks* and "2:30" aloud.
When to use
- Adding a second delivery channel to an agent built for one.
- Writing/editing any shared
respond() / format_reply() / persona-selection helper.
- Voice/phone output sounds text-shaped: spelled numbers, markdown symbols, URLs read aloud.
The core rule
Thread an explicit medium/channel param through every shared response helper, and branch ONLY in a thin edge adapter — never in core agent/tool logic.
reply = await agent.respond(user_text, medium=medium)
- Canonical format in, per-channel transform out. The agent produces text; the chat adapter returns it as-is; the voice adapter strips markdown + speaks numbers as words.
- Adapter contract per channel:
parse_inbound + send_outbound translating to/from a common envelope; keep formatting hints in the envelope metadata, not in the shared logic.
- Per-channel latency/model-tier routing is an adapter decision, not a core-logic branch.
The grep-for-the-leak diagnostic
Enumerate EVERY caller of the shared helper. Any caller on a different channel that doesn't pass medium= is a latent leak. This is the root cause seen in production: a background job that called the shared response helper with no medium → defaulted to the text persona. Test the SAME helper across every channel adapter before shipping a new channel.
Common mistakes
- Building the shared helper for channel #1, adding channel #2 later without a medium param → #2 inherits #1's shape.
- Hardcoding
medium="voice" inside a helper that is ALSO reached by the text gateway (verify the caller set first — shared classes must thread, not hardcode).
- Branching core tool logic on channel instead of at the edge adapter.
Real-world impact
A support bot serving both a web widget and SMS shipped this exact bug: an SMS-side caller invoked the shared respond() helper with no medium= set, so it silently defaulted to the web persona — SMS output came out markdown-shaped with literal asterisks and unescaped links. The fix threaded medium="sms" explicitly into every SMS-side caller. This is the design-time partner to deliver-format-fallback (send-time 4xx recovery) and deliver-durably (crash-survivable delivery) — use all three together. Exa-confirmed against the 2026 "channel adapter pattern" (CallSphere, getwidget conversational-AI architecture).