0022 — Channel Adapter Architecture

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
resolvePersonreal 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 / participantIdresolvePerson / 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:

  1. 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 of dispatcher.ts (which still branches on transport-specific concepts like isTelegramOnlySender, resolveTelegramChatId, settings.smsEnabled). Every inbound webhook hand-parses payloads and hand-resolves identity in its own shape.
  2. 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.
  3. 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

  1. Two adapter interfaces for transports:

    • ChannelAdapter — request-response (SMS, email, Telegram, WhatsApp, iMessage). Implements parseInbound(rawPayload, ctx) → MessageEnvelope and sendOutbound(envelope) → DispatchResult.
    • SessionChannelAdapter — sessions (voice). Implements onSessionStart / onToolCall / onSessionEnd. Voice is bidirectional streaming with mid-session tool calls; forcing it into request-response shape is the wrong abstraction.
  2. One canonical envelopeMessageEnvelope carries identity (participantId), channel/carrier metadata, payload, and origin: 'live' | 'synthetic' | 'replay' | 'shadow'. All adapters produce envelopes; the router and dispatcher consume envelopes. The envelope is Readonly<…> and versioned (envelopeVersion: 1) so future shape changes co-exist with v1.

  3. Outbound transport ≠ channel. Transactional outbound (magic links, vendor templates, tour replies) gets a separate OutboundTransport interface. It has no inbound counterpart, no agent loop, no conversation. The dispatcher routes channel-shaped messages through ChannelAdapters and one-way template-shaped messages through OutboundTransports.

  4. Structured email ingestion ≠ channel. AppFolio NTV emails, AppFolio renewal-signed emails, and vendor-quote PDF parsers get a separate StructuredEmailIngester interface that lives outside lib/messaging/ (in src/lib/integrations/appfolio/email-ingestion/). They produce domain events, not envelopes; the router never sees them.

  5. Identity resolution centralizes. A single resolveParticipant(channel, wireAddress) → participantId helper replaces per-channel identity dance. Today it returns phone or email keyed strings; post-Phase-6a (per ADR-0020) it returns Person.id. One call site, one swap.

  6. Consent is per-channel state on a per-participant record. ConsentRecord keyed by participantId carries TCPA phone-level opt-out (legal canonical), email unsubscribe, WhatsApp pause, Telegram block, and iMessage block. A single isAllowedToSend(envelope, consent) function encapsulates per-channel rules.

  7. 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.

  8. 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 at agents/clara/lib/messaging/adapters/*.ts, and process-envelope.ts is 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.md for the punch list of work that's left.

Out of scope for this ADR

Consequences

What becomes easier

What becomes harder

Hard gates required for the ADR to be Accepted (per §10c persona pass)

  1. feed — Phase 1 contract tests + toAgentContext budget guard. Adapter-contract test asserts every adapter emits a valid envelope and round-trips through router → dispatcher → outbound without losing fields. toAgentContext(envelope) → string enforces 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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

References