RFC 2210 · Channels
One wrapper, every channel.
How we collapse WhatsApp, Telegram, Instagram, Email and a dozen more into one canonical pipeline — so the operator console and the customer app speak the same language, and adding a channel is one file.
Section 01 · the problem
A dozen channels, or one model with a dozen edges?
A naive omnichannel build writes WhatsApp code, then Telegram code, then Instagram code — each with its own contact lookup, its own conversation logic, its own reply path. By channel five the codebase is a museum of near-duplicates.
We studied how mature omnichannel inboxes are built and found the real lever isn't the integrations. It's that every channel collapses into one canonical model behind exactly two seams: an inbound normalizer and an outbound sender. Everything else — contacts, conversations, messages, attachments — is shared.
Our middleware already had the canonical storage. What it lacked was the wrapper. RFC 2210 adds it: a stateless driver engine, a canonical message representation, per-config webhook rotation, and a business-owner Channel Manager — so a new channel is one driver file plus one factory case, and nothing else changes.
Section 02 · architecture
The wrapper sits between the vendors and the canonical store.
Drivers are stateless. Per-config credentials are passed on every call, so one driver instance serves every workspace that uses that channel — and the whole engine is unit-testable without a database.
flowchart TB
subgraph V["Vendors"]
WA["WhatsApp Cloud"]
TG["Telegram"]
IG["Instagram"]
EM["Email / SMS / ..."]
end
subgraph ENGINE["Common Channel Wrapper (internal/channels/driver)"]
FAC["Factory New(kind, provider)"]
DRV["Channel driver
Verify · Parse · Send"]
CAP["Capability descriptor"]
FAC --> DRV
DRV --- CAP
end
CIR["Message Wrapper (CIR)
InboundEvent / OutboundEnvelope"]
subgraph CANON["Canonical store (already existed)"]
ID["user_identities
(contact_inbox)"]
SES["chat_sessions
(conversation)"]
MSG["messages + channel_envelopes"]
end
subgraph CLIENTS["Surfaces"]
WS["Workspace
operator console"]
OU["ouchat
customer app"]
end
V -->|webhook| FAC
DRV --> CIR --> CANON
CANON -->|realtime| WS
CANON -->|realtime| OU
WS -->|reply| DRV --> V
Common Channel Wrapper
A narrow Channel interface — Inbound().Verify/Parse, Outbound().Send, Capabilities() — resolved from a name-keyed factory. The only place that branches on vendor. Mirrors our driver rule for email, SMS, payments, and AI providers.
Message Wrapper (CIR)
A Common International Representation: InboundEvent and OutboundEnvelope reuse the existing content-type structs, so the in-flight shape and the database agree. WhatsApp and the native widget persist identically.
Section 03 · the canonical model
We already had the hard part.
The canonical entities mapped almost 1:1 onto tables we'd already shipped. The wrapper slots underneath features most inboxes lack — takeoff codes, channel health, fallback chains.
| Canonical concept | Omazy (already existed) |
|---|---|
| Inbox + Channel config | app_channel_configs (+ capabilities JSONB) |
ContactInbox (source_id) | user_identities (app, channel, channel_user_id) |
| Conversation | chat_sessions (origin + dispatch channel) |
Message (+ source_id) | messages (ULID, 22 content types) + channel_envelopes |
| Attachment | embedded in messages.content + userfiles |
| Outbound sender | driver Outbound().Send |
| Inbound normalizer | driver Inbound().Parse + persist sink |
Section 04 · inbound
From a signed webhook to a conversation.
One ingress — /api/v1/webhook/:configId — for every channel. The config id is an immutable UUID, so renaming a workspace never breaks a vendor's pasted URL. Verification is per-config; persistence reuses already-audited services.
sequenceDiagram participant Vendor participant WH as Unified Webhook
/api/v1/webhook/:id participant DR as Channel Driver participant SK as Persist Sink participant DB as Canonical Store Vendor->>WH: POST signed payload WH->>DR: Verify(signature, creds) alt signature invalid WH-->>Vendor: 200 (drop + log) else valid WH->>DR: Parse(raw) → InboundEvent[] loop each event WH->>SK: HandleEvents SK->>DB: dedup SET NX (channel, provider_msg_id) SK->>DB: ResolveIdentity → user + identity SK->>DB: published agent → ResolveSession SK->>DB: PersistMessage (+ envelope, realtime) end WH-->>Vendor: 200 end
Idempotency is a Redis SET NX on (channel, provider_msg_id) — webhooks are at-least-once. Signature verification is the real gate: Meta HMAC-SHA256 over the body with the app secret, Telegram's secret-token header, LINE's base64 HMAC. A failed signature is dropped and logged, never surfaced.
Section 05 · outbound
A template-method with guards, then the driver.
Every outgoing message runs the same guards before a driver ever sees it — is it outgoing, not private, not an echo of an inbound message? Then the capability descriptor decides: free-form reply, or template-only.
flowchart LR A["Agent / AI persists
outgoing message"] --> D["Dispatcher"] D --> G{"Guards"} G -->|private / echo| X["skip"] G -->|window open| S["driver.Send(text/media)"] G -->|window closed| T["require approved template"] S --> W["write provider id
+ status"] T --> W S -->|error| F["status=failed
+ health record"]
Section 06 · capabilities
One descriptor gates the backend and both UIs.
The capability struct is the single source of truth. It gates the outbound path (window → template, attachment limits) and drives what the operator console and ouchat render — the same composer affordances, the same "window expired" banner. That's what "same behaviour everywhere" means in practice.
| Channel | Attach | Template | Interactive | Receipts | Window | Initiate |
|---|---|---|---|---|---|---|
| yes | yes | yes | read | 24h | template | |
| Facebook / Instagram | yes | yes | yes | delivered | 24h–7d | yes |
| Telegram | yes | — | yes | — | none | no |
| LINE | limited | — | yes | — | none | no |
| SMS / Email | yes | SMS only | — | — | none | yes |
| TikTok | image | yes | yes | read | 48h | yes |
| Web widget / ouchat | yes | n/a | yes | read | none | yes |
Section 07 · security
The URL is immutable. The secret rotates.
The inbound URL is keyed on the config UUID, so it never changes — a vendor console never breaks. The signing secret rotates on demand.
On rotation the current secret moves to a previous slot and a fresh one is generated. Verification accepts either for a 48-hour grace window, so an in-flight webhook is never dropped mid-rotation. Credentials are sealed with the shared AES-GCM envelope (AAD-bound to app_channel_configs · credentials) and never returned over the API — reads expose only the verify token and the signing secret's last four characters.
Section 08 · the surface
The business owner's journey, end to end.
The Channel Manager lives in the workspace (the business's own admin). It reuses the same channel-config service the platform admin uses — they differ only by scope, never by code path.
flowchart LR
subgraph ENABLE["Enablement"]
C1["Pick channel
from catalog"] --> C2["Authenticate
OAuth / key / token"]
end
subgraph CONNECT["Connect"]
C2 --> C3["Webhook URL
+ verify token"] --> C4["Rotate secret
(48h grace)"]
end
subgraph OPERATE["Operate"]
C4 --> C5["Assign AI agent"] --> C6["See conversations
AI vs human"] --> C7["Respond"]
end
subgraph HANDOFF["Handoff"]
C7 --> C8["Human takeover"] --> C9["Switch channel
(takeoff)"]
end
Section 09 · implementation surface
Where it lives.
Everything below is on main. Adding a channel is a new driver file + a catalog entry; callers, the message model, and the clients don't change.
Middleware (Go)
internal/channels/driver/— wrapper engine: interface, factory,whatsapp_cloud,telegraminternal/channelconfig/— Channel Manager: catalog + capability matrix, secret rotation, CRUDinternal/channels/unified_webhook.go— ingress: verify → parse → sinkinternal/channels/inbound_persist.go— contact → session → messagedb/migrations/000045—channel_type += line, tiktok
Workspace (Next.js)
app/(ws)/brand/channels/_components/channel-manager.tsx— connect wizard, webhook card, credentials formlib/api/channels.ts— typed client (catalog, CRUD, credentials, rotate)
API surface
GET …/channels/catalogPOST/GET/PATCH/DELETE …/channels[/:id]PUT …/channels/:id/credentialsPOST …/channels/:id/webhook/rotate
Sources & further reading
This RFC consolidates two living design docs in the repo:
middleware/docs/common-channel-wrapper.md— the engine + canonical model, withfile:lineevidencemiddleware/docs/channel-manager-and-lifecycle.md— the product surface, 7-epic work plan, and live implementation notes
No public help-centre article exists for channel setup yet — when it ships it will live under the knowledge base, and this section will link to it. Related engineering reading: the Agentic Harness and Background Tasks.
// RFC 2210 · source in middleware/docs/
// edit → open a PR → ships on merge to main