0022 — Channel Adapter Architecture
- Status: Accepted (Phases 1+2 shipped;
resolveParticipantand inline-fallback dedup are tracked open items — see Implementation Status below) - Date: 2026-05-10 (drafted), 2026-05-20 (accepted retroactively — Phase 1 + 2 had already shipped)
- Deciders: Gera (Jose) — author. Persona pass run against
feedandsane(perdocs/planning/channel-adapter-design.md§10). - Related: ADR-0020 (Person as universal human spine —
participantIdresolution depends on this), ADR-0029 (Role-Prompt-Tool Router — consumesparticipantIdfrom the envelope this ADR introduces) - Superseding note: does not supersede prior ADRs. Extends the architecture surveyed in
docs/architecture/multi-channel-architecture.md.
Implementation Status — as of 2026-05-20
The architectural decisions below are AS-DESIGNED and remain authoritative. The phased delivery in §Decision item 8 has partially shipped; the canonical envelope/adapter scaffolding is live in production today. Punch list of what's done vs what's left lives at docs/planning/messaging-canonical-flow-plan.md. Snapshot:
| Component | Shipped? | Code |
|---|---|---|
ChannelAdapter interface |
✅ Live | agents/clara/lib/messaging/channel-adapter.ts |
SessionChannelAdapter (voice) |
✅ Live | agents/clara/lib/messaging/session-channel-adapter.ts |
MessageEnvelope + createEnvelope + toAgentContext budget guard |
✅ Live | agents/clara/lib/messaging/envelope.ts |
OutboundTransport interface |
✅ Live | agents/clara/lib/messaging/outbound-transport.ts |
| Adapter registry | ✅ Live | agents/clara/lib/messaging/registry.ts + registry-singleton.ts |
| Per-channel adapters (SMS, Telegram, SES email, ElevenLabs voice, dashboard-ask-clara) | ✅ Live | agents/clara/lib/messaging/adapters/*.ts |
| Envelope-based inbound processor | ✅ Live | agents/clara/lib/messaging/process-envelope.ts (consumed by lambda/inbound-processor/handler.ts + the local SQS consumer) |
| Consent abstraction | ✅ Live | agents/clara/lib/messaging/consent.ts |
| Dispatcher / Telegram fallback removed | ✅ Live | agents/clara/lib/messaging/dispatcher.ts |
resolvePerson — real implementation |
✅ Live | resolve-person.ts:buildResolvePerson(orgId) returns a closure that calls ensurePersonByClaim against the spine — find-or-create-Person, returns Person.id. Wired at every production webhook (Twilio SMS, voice personalization observer, email ingest observer + Lambda email path, conversations PM-query, Telegram). The contract + field were renamed from resolveParticipant / participantId → resolvePerson / personId to reflect the field's actual job: holding the canonical spine identity. stubResolvePerson survives only as a test fixture; production routes use buildResolvePerson. Drift-guarded by legacy-field-elimination.drift.test.ts (pattern: envelope.participantId). |
| SQS-bypass inline fallback paths (Twilio + Telegram webhooks) → envelope | ✅ Live | Both webhooks (src/app/api/twilio/webhook/route.ts + src/app/api/telegram/webhook/route.ts) now build the envelope once via adapter.parseInbound(...) and route every code path through `processEnvelope(envelope, { source: 'Twilio Webhook' |
| Synthetic-origin path for evals (Phase 3) | ⏳ Pending | Pipeline Lab still uses its legacy harness shape |
| Remove Telegram from fallback chain (Phase 4) | ⏳ Pending | The "Telegram doing three jobs" item from §Context is partially resolved (dispatcher.ts cleanup landed); test-rig role still uses Telegram |
| WhatsApp adapter (Phase 5) | ⏳ Pending | — |
| iMessage adapter via Sinch (Phase 6) | ⏳ Pending | — |
| Outlook Graph adapter (out of scope per §Out of scope) | 🚫 Deferred by design | inbox_email stays on legacy InboxEmailPayload + processEmailRecord() |
The "What becomes easier" / "What becomes harder" sections below describe the target steady-state assuming the open items above land. Today's reality is past "Phase 2 done" — pre-canonical-envelope friction is gone AND the inline-fallback paths have been unified through processEnvelope. The remaining open items are (a) the real resolveParticipant implementation (still gating ADR-0029 Phase 3 functionally) and (b) the eval-rig migration (Phase 3).
Context
PropFlow's communication layer evolved one transport at a time. Today it has SMS (Twilio), voice (ElevenLabs native + legacy Twilio TwiML), email (SES inbound + SendGrid outbound + Microsoft Graph for PM mailboxes), and Telegram, all converging on a shared inbound router (agents/clara/lib/messaging/inbound-router.ts) and a shared outbound dispatcher (agents/clara/lib/messaging/dispatcher.ts). The system works in production, but three structural problems compound:
- Outbound is half-adapted; inbound is fully bespoke.
ChannelCapabilities(agents/clara/lib/messaging/channel-capabilities.ts:13) is a unified outbound abstraction that reaches the prompt layer but stops short ofdispatcher.ts(which still branches on transport-specific concepts likeisTelegramOnlySender,resolveTelegramChatId,settings.smsEnabled). Every inbound webhook hand-parses payloads and hand-resolves identity in its own shape. - Telegram is silently doing three jobs. It's (a) a real production transport for users who prefer it, (b) the SMS-disabled fallback in
dispatcher.ts:74–115, AND (c) the only channel the team uses for human-in-the-loop end-to-end testing. That overload is where "I'm getting things on both and it's confusing" symptoms originate. - Eval and replay aren't a first-class property of a message. Pipeline Lab is a code-path validator that bypasses the carrier; the rest of eval traffic uses Telegram and eyeballs. Neither is "real production carrier shape, deterministic, isolated."
We also have committed roadmap interest in WhatsApp (next channel) and iMessage (gated on Sinch as MSP, since Apple is not onboarding new direct partners as of 2026). Adding either onto today's architecture means another bespoke webhook plus another set of conditional branches in dispatcher.ts. The accumulation does not have a stable end-state.
The full design memo with prior-art survey (Twilio Conversations, Bird, Intercom, Sinch sandbox, hexagonal architecture, GovTech-on-Telegram-E2E-testing), four design options, persona pass, and concrete TypeScript shapes lives at docs/planning/channel-adapter-design.md. This ADR is the binding decision distilled from it.
Decision
Adopt a symmetric channel-adapter architecture, with MessageEnvelope as the canonical contract between adapters, router, and dispatcher, and origin as a first-class property of every message.
Concretely
Two adapter interfaces for transports:
ChannelAdapter— request-response (SMS, email, Telegram, WhatsApp, iMessage). ImplementsparseInbound(rawPayload, ctx) → MessageEnvelopeandsendOutbound(envelope) → DispatchResult.SessionChannelAdapter— sessions (voice). ImplementsonSessionStart/onToolCall/onSessionEnd. Voice is bidirectional streaming with mid-session tool calls; forcing it into request-response shape is the wrong abstraction.
One canonical envelope —
MessageEnvelopecarries identity (participantId), channel/carrier metadata, payload, andorigin: 'live' | 'synthetic' | 'replay' | 'shadow'. All adapters produce envelopes; the router and dispatcher consume envelopes. The envelope isReadonly<…>and versioned (envelopeVersion: 1) so future shape changes co-exist with v1.Outbound transport ≠ channel. Transactional outbound (magic links, vendor templates, tour replies) gets a separate
OutboundTransportinterface. It has no inbound counterpart, no agent loop, no conversation. The dispatcher routes channel-shaped messages throughChannelAdapters and one-way template-shaped messages throughOutboundTransports.Structured email ingestion ≠ channel. AppFolio NTV emails, AppFolio renewal-signed emails, and vendor-quote PDF parsers get a separate
StructuredEmailIngesterinterface that lives outsidelib/messaging/(insrc/lib/integrations/appfolio/email-ingestion/). They produce domain events, not envelopes; the router never sees them.Identity resolution centralizes. A single
resolveParticipant(channel, wireAddress) → participantIdhelper replaces per-channel identity dance. Today it returns phone or email keyed strings; post-Phase-6a (per ADR-0020) it returnsPerson.id. One call site, one swap.Consent is per-channel state on a per-participant record.
ConsentRecordkeyed byparticipantIdcarries TCPA phone-level opt-out (legal canonical), email unsubscribe, WhatsApp pause, Telegram block, and iMessage block. A singleisAllowedToSend(envelope, consent)function encapsulates per-channel rules.Telegram is demoted to one job. It remains a real production transport for users who prefer it. The "SMS-disabled fallback" and "test rig" jobs migrate to
origin: 'synthetic'/origin: 'replay'. Pipeline Lab and eval suites switch to producing synthetic-origin envelopes.Phased delivery (10 weeks core; +2 WhatsApp; +6-8 elapsed iMessage; gates between phases). Phase 1 is purely additive scaffolding (this PR). Phase 2 migrates each existing webhook one at a time. Phase 3 introduces the synthetic-origin path for evals. Phase 4 removes Telegram from the fallback chain. Phase 5 ships WhatsApp via Twilio. Phase 6 ships iMessage via Sinch.
Status update (2026-05-20): Phase 1 shipped in PR #818 (
feat(messaging): channel adapter foundation). Phase 2 followed across SMS, Telegram, SES email, and ElevenLabs voice — adapters live atagents/clara/lib/messaging/adapters/*.ts, andprocess-envelope.tsis the canonical inbound handler consumed by both the Lambda and the local SQS consumer. Phase 3 (synthetic-origin evals) + Phase 4 (Telegram fallback removal) + Phases 5-6 remain pending. See the Implementation Status table above +docs/planning/messaging-canonical-flow-plan.mdfor the punch list of work that's left.
Out of scope for this ADR
- Adopting Twilio Conversations / Bird as the substrate (Option D in the memo). Worth re-evaluating in 12 months at higher scale. The adapter shape we adopt is compatible with that future migration, not blocking on it.
- Cross-channel conversation continuity (the "she remembers you switched from text to email" feature). Enabled by the envelope; delivered in a future Phase 7.
- Vercel Workflow DevKit as substrate for voice sessions or synthetic replay. Flagged as a follow-up exploration in §11 of the memo.
- Migrating
inbox_email(Microsoft Graph poller for PM Outlook mailboxes) to the channel-adapter pattern. The'graph'slot in theCarrierunion is reserved but intentionally unused at Phase 1-4. Reasoning: every PM-connected mailbox carries its own OAuth token, refresh schedule, and threading-aware delivery callback (Graph'screateReply→patch→sendpattern). This is fundamentally stateful — it doesn't fit a statelessChannelAdapterconstructor that assumes one shared carrier credential. Migrating would require either pushing OAuth refresh into adapter constructors (violating stateless) or introducing per-property delivery callbacks parallel to the registry (defeating the unified shape).InboxEmailPayloadsurvives as a legacy ingestion notification feedingprocessEmailRecord()→routeInboundMessage(). Pipeline Lab tests this path through its legacy harness, not through the envelope path. A future Graph adapter would require re-architecting the dispatcher's outbound delivery contract — that's a separate project, not part of the channel-adapter migration. - Voice in Pipeline Lab. Voice is request/response with session state and multiple tool-call turns during a live call — a fundamentally different shape from the one-shot inbound funnel that Pipeline Lab inspects. Phase 1's per-channel envelope eval covers ElevenLabs adapter contract correctness; full-funnel voice testing today happens via real ElevenLabs sessions. Future strategy: Twilio Programmable Voice (TwiML) can dial out and connect to an ElevenLabs WebRTC session — that's the natural shape for a self-test where Pipeline Lab triggers an outbound call to a configured number, the call connects to our own agent, and the SSE stream captures every tool turn. Tracked as Phase 7 work alongside cross-channel continuity.
Consequences
What becomes easier
- Adding a new channel is a single file:
src/lib/messaging/adapters/<carrier>.tsimplementingChannelAdapter. WhatsApp ships ~1 week after Phase 1 lands. iMessage ships ~1 week of code time once Sinch onboarding completes. - Evals run against the same code as production, with no carrier hit, no PII risk, and deterministic outputs. The synthetic-origin path eliminates Telegram-as-eval-rig confusion.
dispatcher.tsshrinks from ~200 lines of conditional fallbacks to ~30 lines of typed routing — consent → origin → registry.- Webhooks become 'auth + idempotency + delegate to adapter + call router'. Each is ~50 lines instead of ~300.
- TCPA consent becomes one query, one decision instead of scattered checks. Adding a new channel means one new field on
ConsentRecord, not new logic in multiple files.
What becomes harder
MessageEnvelopeis a public contract. Every adapter, every test, every fixture inherits its shape. Breaking it requires coordinated migration.resolveParticipantis the new single point of failure. If it's slow, every channel is slow. Performance budget (<50ms p99) and circuit-breaker are mitigations.- Phase 1 ships invisible scaffolding. No production behavior changes. The PR has to defend "is this worth it?" against ~2 weeks of no payoff before Phase 2 starts shipping channel migrations. (Past tense as of 2026-05-20 — Phase 1 + 2 are live.)
- The migration touches every webhook eventually. Phase 2 spans ~3 sprints across SMS, email, Telegram, voice. Each must ship independently revertable. (Done for the four primary webhooks. Outlook Graph remains on the legacy
processEmailRecord()path by design — see §Out of scope.) - Inline-fallback structural duplicate. The Twilio + Telegram webhooks build envelopes for the SQS path but still call
routeInboundMessage(...)directly in the inline-fallback branch (SQS unset / message >250KB / SQS publish failure). Functionally correct, but it duplicates identity resolution and bypasses envelope metadata. Cleanup target: have the fallback branch callprocessEnvelope(envelope, ctx)inline instead. Tracked indocs/planning/messaging-canonical-flow-plan.md.
Hard gates required for the ADR to be Accepted (per §10c persona pass)
feed— Phase 1 contract tests +toAgentContextbudget guard. Adapter-contract test asserts every adapter emits a valid envelope and round-trips through router → dispatcher → outbound without losing fields.toAgentContext(envelope) → stringenforces an upper-bound token budget so envelope metadata never leaks into the agent's context window. Both must land in the Phase 1 PR — not Phase 3.feed— phase-boundary cut-bait clauses. Each phase must be standalone-revertable. Phases 3-6 are gated on the prior phase holding in production for ≥2 weeks. Cut bait at any phase boundary if reliability slips.feed— Sinch trial gate before iMessage commits. No fees paid until 2-week real-integration trial against Sinch sandbox proves the adapter shape covers their payload types (rich-link, list-picker, time-picker).sane— no "coming soon" badges on the public site. Customer-facing copy lags shipped functionality. WhatsApp/iMessage marketing copy lands when the adapter ships, not before.sane— Phase 6 kickoff gated on ≥2 buyer interviews naming iMessage as a buying criterion. If the asks don't materialize by end of Phase 5, drop Phase 6.
Known shortcomings (per §11.5 of the memo)
The design memo names ten weaknesses with mitigations and canaries: envelope as new public contract; cross-channel continuity not delivered; participant resolution is a SPOF; synthetic origin can't fully simulate carrier failure modes; email three-role split is invasive; WhatsApp 24-hour window is stateful policy; iMessage rich payloads may not fit v1; persona gates can erode under deadline pressure; Workflow DevKit is a "watch this"; Phase 1 is additive scaffolding before payoff. Each has a concrete mitigation; the ADR commits to the mitigations.
Eleventh shortcoming (caught in Turn 7 closure review): the migration does not unify all inbound text-like channels — inbox_email (Microsoft Graph for PM Outlook mailboxes) stays on the legacy InboxEmailPayload shape. This is intentional (see "Out of scope" above) because per-PM OAuth tokens + per-mailbox delivery callbacks don't fit the stateless adapter contract. The cost: two production "shapes" for inbound email — SES (envelope) and Graph (legacy). The mitigation: Pipeline Lab inspects both paths under one harness, so the operator gets unified observability even though the underlying code paths differ. The reservation of 'graph' in the Carrier union keeps the option open without committing to it.
Alternatives considered
- Option A — Status quo plus naming. Document the Telegram triple-job, leave the dispatcher's fallback chain in place. Rejected: solves nothing structural; the confusion compounds with each new channel.
- Option B — Symmetric inbound adapters, no envelope refactor. Lift webhook parse logic into adapters but keep the router's existing argument shape. Rejected: pays down inbound bespoke-ness but doesn't fix eval/test confusion or the dispatcher's fallback tangle.
- Option D — Adopt Twilio Conversations or Bird as substrate. Hand off
Conversation+Participantmodeling to a vendor. Rejected for now: requires rewriting identity resolution against vendor's participant model, accepts vendor lock-in and per-conversation pricing, and the simplification is real but premature at current scale. Worth re-evaluating at 100x scale.
References
- Design memo:
docs/planning/channel-adapter-design.md - Current-state architecture:
docs/architecture/multi-channel-architecture.md - Identity model:
docs/adr/0020-person-as-universal-human-spine.md - Twilio Conversations: https://www.twilio.com/docs/conversations
- Bird Channels API: https://docs.bird.com/api/connectivity-platform-migration-guide/channels-api-and-conversations-api
- Twilio WhatsApp 24-hour window: https://www.twilio.com/docs/whatsapp/key-concepts
- Apple Messages for Business / Sinch as MSP: https://sinch.com/apis/messaging/apple-messages-business/
- Hexagonal architecture for chatbots: https://shivaramp.medium.com/hexagonal-architecture-for-genai-chatbots-decoupling-ai-logic-from-the-rest-fef1a162330c