# WhatsApp, Email, Push, SMS, Discord, and Slack

This page documents every non-Telegram messaging surface V1 ships: WhatsApp
Business, email, push (FCM, APNs, Expo, and W3C Web Push), SMS, Discord, and
Slack. It serves customers (transactional and grounded delivery on whichever
channel they have opted into), tenants and institutions (residency-aware
allowlists, per-message cost ledgers, audit), and operators (deliverability
monitoring and a credential→transport seam that fails loud instead of faking a
send). All of it lives in one Nx library, `@oshun/messaging-channels`
(`libs/oshun/messaging-channels/src`), and it sits beneath the shared routing
spine described in
[Channel Abstraction, Routing, and Boundaries](./channel-abstraction-and-boundary.md);
its sibling surface page is [Telegram Surfaces](./telegram-surfaces.md).

The single rule that shapes everything here is the same rule the channel
abstraction enforces: **no `aaa-creator` or `operator-admin` intent ever reaches
a messaging channel, and nothing is reported as "sent" unless a real provider
transport actually sent it.** Where a transport is a pure planner that still
needs live credentials, this page says so explicitly rather than implying a
shipped integration.

## Where this lives in V1

Each channel is implemented as a small, pure-data adapter (the planner /
payload-builder), plus a real provider transport that the credential seam wires
in only when every credential is present:

| Channel         | Adapter (planner / payload)                          | Real transport (HTTP / protocol)                                                         |
| --------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| WhatsApp        | `whatsapp/index.ts` (`planWhatsAppTemplateDelivery`) | `sendWhatsAppViaMetaCloud` (`transports.ts`)                                             |
| Email           | `email/index.ts` (`buildTransactionalEmail`)         | `sendEmailViaSendgrid` (`transports.ts`), `sendEmailViaSmtp` (`smtp-email-transport.ts`) |
| Push (FCM)      | `push/index.ts` (`buildPushPayload`)                 | `sendPushViaFcm` (`transports.ts`)                                                       |
| Push (APNs)     | `push/index.ts`                                      | `sendPushViaApns` + `buildApnsProviderJwt` (`transports.ts`)                             |
| Push (Expo)     | `push/index.ts`                                      | `sendPushViaExpo` (`transports.ts`)                                                      |
| Push (Web Push) | `push/index.ts`                                      | `sendPushViaWebPush` (`web-push-transport.ts`)                                           |
| SMS             | `sms/index.ts` (`planSmsDelivery`)                   | `sendSmsViaTwilio` (`transports.ts`)                                                     |
| Slack           | `slack/index.ts` (`renderSlackOshunCommand`)         | `sendSlackViaWebApi` (`transports.ts`)                                                   |
| Discord         | `discord/index.ts` (`renderDiscordAskCommand`)       | `sendDiscordViaBot` (`transports.ts`)                                                    |

The seam between deploy-time secrets and these transports is
`provider-config-env.ts`; the registry that classifies every channel is
`registry.ts`; the routing decision that picks one of them is `dispatcher.ts`;
and the per-channel content-class and audit rules are `boundary.ts`. The
companion engineering reference is
[../architecture/messaging-channels.md](../architecture/messaging-channels.md).

> **Accuracy note (architecture pointer).** Earlier architecture text located
> the email/push/SMS adapters in `libs/shared/inbound-integrations/`. The real
> outbound messaging adapters live in
> `libs/oshun/messaging-channels/src/{email,push,sms}` and `transports.ts`.
> `libs/shared/inbound-integrations` is the inbound-connector and payment side,
> not the outbound transport layer.

## The twelve registered channels

The prose summary historically named a narrower set — "SMS (Twilio)" and "push
(FCM/APNs)". The registry has broadened well past that. `registry.ts` enumerates
**twelve** concrete channel IDs in `CHANNEL_IDS`:

```
telegram-bot, telegram-channel, telegram-miniapp,
whatsapp-business, discord-bot, slack-app, sms-twilio,
push-fcm, push-apns, push-expo, push-webpush, email-ses
```

Each ID carries a frozen `ChannelEntry` describing what the channel can do and
how it must be governed. The taxonomy the dispatcher reads is:

| Dimension    | Constant             | Values                                                                          |
| ------------ | -------------------- | ------------------------------------------------------------------------------- |
| Capabilities | `CAPABILITY_KINDS`   | `text`, `rich-cards`, `inline-buttons`, `voice`, `files`, `payments`, `miniapp` |
| Cost class   | `COST_CLASSES`       | `free`, `low`, `metered`, `high`                                                |
| Residency    | `RESIDENCY_PROFILES` | `global`, `eu-only`, `us-only`, `apac-only`, `self-hosted`                      |
| Consent      | `CONSENT_PROFILES`   | `platform-tos-implied`, `verified-opt-in-required`, `double-opt-in-required`    |
| Retention    | `RETENTION_PROFILES` | `channel-default`, `short-30d`, `standard-365d`, `long-3y`, `indefinite`        |

Each non-Telegram channel's frozen registry row (from `CHANNEL_REGISTRY`):

| Channel             | Capabilities                                   | Cost      | Consent                    | Retention         | Crisis-capable |
| ------------------- | ---------------------------------------------- | --------- | -------------------------- | ----------------- | -------------- |
| `whatsapp-business` | text, rich-cards, inline-buttons, voice, files | `metered` | `double-opt-in-required`   | `short-30d`       | yes            |
| `discord-bot`       | text, rich-cards, inline-buttons, voice, files | `free`    | `platform-tos-implied`     | `channel-default` | no             |
| `slack-app`         | text, rich-cards, inline-buttons, files        | `free`    | `platform-tos-implied`     | `channel-default` | no             |
| `sms-twilio`        | text                                           | `metered` | `double-opt-in-required`   | `short-30d`       | yes            |
| `push-fcm`          | text, rich-cards, inline-buttons               | `free`    | `verified-opt-in-required` | `short-30d`       | no             |
| `push-apns`         | text, rich-cards, inline-buttons               | `free`    | `verified-opt-in-required` | `short-30d`       | no             |
| `push-expo`         | text, rich-cards, inline-buttons               | `free`    | `verified-opt-in-required` | `short-30d`       | no             |
| `push-webpush`      | text, rich-cards, inline-buttons               | `free`    | `verified-opt-in-required` | `short-30d`       | no             |
| `email-ses`         | text, rich-cards, files                        | `low`     | `verified-opt-in-required` | `standard-365d`   | no             |

`crisisCapable` is the property that lets the dispatcher route a
`crisis-hotline` intent to WhatsApp or SMS but never to a push platform or a
community surface. `disclosureRenderable` records whether the channel can carry
visible disclosure copy in the message body (true for WhatsApp/email; false for
SMS and push, where disclosure rides in payload metadata).

> **Naming artifact.** The email channel id is `email-ses`, but there is no
> dedicated SES API transport in the lib — the real transports are
> `sendEmailViaSendgrid` (SendGrid HTTPS API) and `sendEmailViaSmtp` (a
> hand-written RFC 5321 SMTP client that also speaks to AWS SES SMTP, Postmark
> SMTP, or the dev-stack Mailpit sink). DKIM/SPF/DMARC are deploy-config
> concerns, not code in this lib.

## How an outbound message is routed and sent

A send is a two-stage pipeline: a **pure routing decision** followed by a **real
transport call** wired in only when credentials exist.

1. **Route** — `dispatchMessage(input)` (`dispatcher.ts`) is a pure function
   from `DispatchInput` to `DispatchDecision`. It enforces, in order: the tier
   guard, crisis suppression, quiet hours, frequency caps, a non-empty tenant
   allowlist, and then per-channel opt-in / residency / crisis-capability /
   content-class / capability checks. It returns either
   `{ kind: 'dispatch', channel, contentClass, chosenVariant }` or
   `{ kind: 'suppress', reason }` with a typed reason such as
   `tier-not-allowed-on-messaging`, `no-opt-in`, or `content-class-not-allowed`.
2. **Send** — `deliverWithEnvProviders({ decision, recipient, ... })`
   (`provider-config-env.ts`) builds the provider config from the environment
   and calls `deliverDispatchedMessage`, which invokes the channel's real
   transport. If the chosen channel's credentials are not present, it returns
   `missing-config`; if the recipient lacks the needed address, it returns
   `missing-recipient`; a non-dispatch decision returns `suppressed`. **None of
   these is a fabricated success.**

### The tier and intent model

The dispatcher's `TIERS` are
`['contemplative', 'curated-creator', 'aaa-creator', 'operator-admin']`, but
only the first two are in `MESSAGING_ALLOWED_TIERS`. Any `aaa-creator` or
`operator-admin` intent is suppressed with `tier-not-allowed-on-messaging`
before any channel is even considered — this is the concrete enforcement of
"tier-aware routing." Intents are typed by `INTENT_KINDS`:

```
transactional-receipt, crisis-hotline, reminder,
session-notification, newsletter, engagement-recap
```

Each intent maps to a `ContentClass` via `INTENT_CONTENT_CLASSES` (for example
`reminder → ritual-reminder`, `newsletter → editorial-briefing`,
`engagement-recap → grounded-answer`), and each channel boundary lists which
content classes it will accept. `crisis-hotline` is the only intent allowed to
bypass crisis suppression, quiet hours, and frequency caps, and it must route
only to a `crisisCapable` channel.

Each message arrives as one or more `ContentVariant` objects, each carrying a
`requiredCapability`, `bodyTextPlain`, `disclosureCopy`, and `provenanceFooter`.
`chooseVariantForChannel` picks the first variant whose capability the channel
supports, falling back to a text variant so a rich-card message still degrades
to plain text on SMS rather than being dropped.

## WhatsApp Business — transactional template messaging

WhatsApp delivery is template-only and Meta-compliant. `WhatsAppUseCase` is the
allowed set:

```
receipt, reminder, password-reset, login-code, billing-alert, scheduled-event-nudge
```

`planWhatsAppTemplateDelivery(templates, request)` is pure-data so it is
testable without a Meta account. Its rules:

- It throws unless `optInActive` is true (Meta requires opt-in for every
  outbound).
- It enforces the **24-hour customer-initiated session window**
  (`SESSION_WINDOW_SECONDS = 24 * 60 * 60`). The window is open only when a
  `customerInitiatedAtUnixSeconds` is present and within 24h of `now`.
- It looks for an approved template matching name, locale, and use-case
  (`findApprovedTemplate`, which requires `approved === true`). If none exists,
  it returns an **email-fallback** plan — `no-approved-template` when the window
  is open, `session-window-closed-no-template` when it is closed — and throws if
  no `emailFallbackAddress` was supplied.
- On success it returns the chosen template plus a `CostLedgerEntry`
  (`channel: 'whatsapp-business'`, locale, `amountMinor`, use-case) bound to
  tenant cost controls.

`ingestWhatsAppStatusWebhook` normalizes Meta's delivery webhook
(`sent | delivered | read | failed | opted_out`) into a tenant-scoped key. The
real provider call, `sendWhatsAppViaMetaCloud`, POSTs to
`https://graph.facebook.com/v21.0/<phoneNumberId>/messages` with
`messaging_product: 'whatsapp'`, `type: 'template'`, and the body parameters; it
reads the returned `messages[0].id` and returns `{ ok: false }` on any non-2xx.
**The planner is real; sending requires live Meta Cloud API credentials.**

## Email — SendGrid HTTPS or a hand-written SMTP client

`buildTransactionalEmail` builds the deliverable plan with a hard DKIM/SPF/DMARC
gate: it **throws** unless the tenant config has `dkimVerified`, `spfVerified`,
and a `dmarcPolicy` that is not `none`. The plan attaches `List-Unsubscribe` and
`List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers plus
`X-Oshun-Locale` and `X-Oshun-Tenant`, and concatenates the disclosure copy and
provenance footer onto the body.

Inbound deliverability events flow through `ingestEmailWebhookEvent`
(`sent | bounce | complaint | unsubscribe | open | click`): a permanent bounce,
a complaint, or an unsubscribe suppresses the recipient with a typed
`EmailSuppressionReason` (`hard-bounce`, `spam-complaint`, `list-unsubscribe`,
`manual-block`); `isEmailDeliverable` then checks the suppression list before
the next send.

Two real transports back the email channel:

- **`sendEmailViaSendgrid`** — POSTs to `https://api.sendgrid.com/v3/mail/send`
  with the SendGrid v3 shape (`personalizations`, `from`, `subject`, `content`)
  and reads the `x-message-id` response header. Returns `{ ok: false }` on any
  non-2xx.
- **`sendEmailViaSmtp`** (`smtp-email-transport.ts`) — a **dependency-free RFC
  5321 SMTP client** over `node:net` / `node:tls`. It speaks the protocol
  directly: greeting → `EHLO` → optional `STARTTLS` → optional `AUTH`
  (`AUTH PLAIN` if advertised, else `AUTH LOGIN`, failing loud if neither) →
  `MAIL FROM` → `RCPT TO` → `DATA` → `QUIT`. It builds an RFC 5322 message
  (stripping CR/LF from injected header values to prevent header injection),
  **dot-stuffs** the body per RFC 5321 §4.5.2, and returns `{ ok: false }` with
  the failing status on any non-2xx reply, dropped connection, or timeout —
  **never a fabricated success.** The socket factory and the STARTTLS upgrade
  are injectable, so the full state machine is unit-tested against an in-process
  SMTP server with real sockets. This is the transport used for AWS SES SMTP,
  Postmark SMTP, a corporate relay, and the dev-stack Mailpit sink (`:1025`).

The credential seam (`provider-config-env.ts`) prefers SMTP when
`OSHUN_SMTP_HOST` is set, otherwise falls back to SendGrid when
`OSHUN_SENDGRID_API_KEY` is set; `OSHUN_MESSAGING_EMAIL_FROM` is required either
way.

## Push — FCM, APNs, Expo, and W3C Web Push

The push surface is wider than the historical "FCM (Android/web) and APNs (iOS)"
description. The payload builder, `buildPushPayload` (`push/index.ts`), still
synthesizes the three core platform shapes named in `PushPlatform`
(`fcm-android`, `fcm-web`, `apns-ios`). That covers silent background-sync
pushes (which may not expose user-visible actions), badge management,
action-button categories, deep-link click-actions, and thread IDs. But
`CHANNEL_IDS` additionally registers `push-expo` and `push-webpush`, each with
its own real transport:

- **`sendPushViaFcm`** — POSTs to
  `https://fcm.googleapis.com/v1/projects/<projectId>/messages:send` (FCM HTTP
  v1) and reads the returned `name`.
- **`sendPushViaApns`** — POSTs to `https://api.push.apple.com/3/device/<token>`
  (or the sandbox host) over what must be an HTTP/2 transport. It builds a real
  **ES256 provider JWT** with `buildApnsProviderJwt` (P-256, IEEE-P1363 `r‖s`
  signature, `{ alg:'ES256', kid }` header), sets `apns-topic` /
  `apns-push-type`, and reads the `apns-id` header. Because Node's global
  `fetch` is HTTP/1.1, `apnsHttp2BootWarning` emits a startup warning when APNs
  is configured but no HTTP/2 `fetchImpl` (`createHttp2TransportFetch`) is wired
  in.
- **`sendPushViaExpo`** — POSTs to `https://exp.host/--/api/v2/push/send` for
  React Native / Expo `ExponentPushToken[...]` clients. It inspects the returned
  Expo **ticket** and reports an `error` ticket (e.g. `DeviceNotRegistered`) as
  a failure — never a faked success.
- **`sendPushViaWebPush`** (`web-push-transport.ts`) — the full **W3C Web Push
  Protocol** for PWAs. See the callout below.

### Web Push is real, RFC-grade cryptography

`web-push-transport.ts` implements the Web Push stack end-to-end: RFC 8030
(protocol), `aes128gcm` payload encryption per RFC 8291 + RFC 8188, and VAPID
application-server identification per RFC 8292. Because the lib is
client-transpiled (so `node:crypto` is unavailable), every primitive comes from
the audited noble suite: `@noble/curves` for P-256 ECDH and ES256,
`@noble/hashes` for HKDF/SHA-256, and `@noble/ciphers` for AES-128-GCM. To
deliver, it derives a fresh ephemeral P-256 key against the client's `p256dh`,
derives the content-encryption key and nonce (`deriveWebPushEncryptionKeys`),
AES-128-GCM encrypts and frames the payload, and signs a VAPID ES256 JWT
(`buildVapidAuthorizationHeader`). The encryption is **verified byte-for-byte
against the RFC 8291 Appendix A known-answer test** in
`web-push-transport.test.ts`. A non-2xx push-service response is reported as
`{ ok: false }`, never faked.

`ingestPushDeliveryReceipt` maps a provider receipt
(`sent | delivered | failed | revoked`) onto a metric event and a token status,
flipping a token to `revoked` so it stops receiving sends.

## SMS — verified-identity flows only, with A2P 10DLC and a cost ledger

SMS is restricted to verified-identity and safety flows. `SmsUseCase` is:

```
login-code, password-reset, billing-alert, crisis-hotline, verified-reminder
```

`planSmsDelivery(request)` enforces the rules and computes cost:

- It **throws** for a US recipient when `a2p10DlcRegistered` is false (A2P 10DLC
  compliance is mandatory for US delivery).
- It computes `segmentCount` at 160 chars/segment (`smsSegments`) and prices the
  message from `SMS_PRICING_MINOR_USD`, a per-country minor-USD table (`US` 1,
  `CA` 1, `GB` 4, `IE` 5, `DE` 8, `FR` 8, `ES` 5, `IT` 7, `AU` 5, `NZ` 8, `MX`
  5, `BR` 4, `IN` 3, `ZA` 3), with a `SMS_PRICING_FALLBACK_MINOR_USD` of 6 for
  unlisted countries.
- It returns a `SmsCostLedgerEntry` (`channel: 'sms-twilio'`, country, use-case,
  `amountMinor = price * segmentCount`, `currency: 'USD'`, `segmentCount`).

`ingestSmsStatusWebhook` maps the Twilio status
(`queued | sent | delivered | failed | undelivered`) to a metric event and
suppresses the recipient on `failed`/`undelivered`. The real transport,
`sendSmsViaTwilio`, POSTs `x-www-form-urlencoded` `To/From/Body` to
`https://api.twilio.com/2010-04-01/Accounts/<sid>/Messages.json` with HTTP Basic
auth and reads the returned `sid`. **The planner and pricing are real; live
sending needs Twilio credentials** (`OSHUN_TWILIO_ACCOUNT_SID`,
`OSHUN_TWILIO_AUTH_TOKEN`, `OSHUN_TWILIO_FROM`, optional
`OSHUN_TWILIO_A2P_10DLC_COUNTRIES`).

## Discord — AAA-creator community surfaces

The Discord adapter is deny-by-default and bound to specific communities.
`DiscordServerBinding.community` is the literal union
`'yemaya-aaa-creator-community' | 'project-obsidian-production'`.
`assertDiscordEntitlement(binding)` throws unless the binding is `installed` and
holds both `REQUIRED_DISCORD_SCOPES` (`applications.commands`, `bot`); an
unbound server is refused. `renderDiscordAskCommand` reuses the Telegram
grounded-answer renderer (`renderGroundedTelegramAnswer`) so Sophia grounding
discipline matches the Telegram bot's. The boundary entry for `discord-bot`
allows only the `editorial-briefing` content class — Discord is for
creator-community editorial surfaces, not transactional or crisis delivery, and
it is **not exposed on the contemplative product.** The real transport
`sendDiscordViaBot` POSTs to
`https://discord.com/api/v10/channels/<id>/messages` with a `Bot <token>`
authorization header.

## Slack — institutional Metis delivery

The Slack adapter is gated to Metis institutions. `assertSlackEntitlement`
requires the binding to be `installed`, the `metisInstitutionId` to start with
the literal prefix `metis-institution:`, and both `REQUIRED_SLACK_SCOPES`
(`commands`, `chat:write`) to be present — otherwise it throws. Workspace
binding happens through a per-workspace OAuth install.

`renderSlackOshunCommand` handles `/oshun today | save | ask` with the same
grounding discipline as the Telegram bot:

- `/oshun ask` requires a real grounded answer and `GroundingState`, rendered
  through `renderGroundedTelegramAnswer`; it throws if grounding input is
  missing.
- `/oshun today` renders the recipient's **real** `todayItems` —
  `"Nothing is scheduled for you today."` when the list is empty, never a
  fabricated agenda.
- `/oshun save` returns a real confirmation only when a `savedSummary` is
  present; otherwise it honestly states
  `"Saving from Slack is not connected, so I will not claim it saved."`

The boundary allows the `slack-app` channel the `transactional`,
`grounded-answer`, and `editorial-briefing` content classes. The real transport
`sendSlackViaWebApi` POSTs to `https://slack.com/api/chat.postMessage` and
treats Slack's HTTP-200-with-`{ ok:false }` logical errors as failures.

## The channel boundary, audit envelope, and memory guard

`boundary.ts` is the implementation behind the narrative "channel boundary." Its
`ContentClass` union is:

```
transactional, grounded-answer, ritual-reminder, editorial-briefing,
identity-verification, billing, crisis
```

`CHANNEL_BOUNDARIES` lists, per channel, the `allowedContentClasses`, residency,
retention, the human-readable consent requirement, whether provenance is
preserved, the `disclosureMechanism`
(`body-copy | body-copy-and-link | payload-metadata`), and the `auditEvents`
emitted. For example, `sms-twilio` allows
`identity-verification, billing, crisis, ritual-reminder` with
`provenancePreserved: false`, while `email-ses` allows
`transactional, billing, editorial-briefing, grounded-answer, ritual-reminder`
with `body-copy-and-link` disclosure and `standard-365d` retention.

Every send/receive emits a `ChannelAuditEnvelope` built by
`buildChannelAuditEnvelope`, with the fields the docs describe narratively:

```json
{
  "channelId": "email-ses",
  "recipientId": "...",
  "intentClass": "grounded-answer",
  "persona": "...",
  "policyHash": "...",
  "provenanceBundleId": "...",
  "residencyTag": "global",
  "retentionClass": "standard-365d",
  "disclosureVerificationResult": "passed"
}
```

`decideMemoryIngestion` is the Iris memory guard: a durable write
(`durableWriteAllowed`) requires both the `rememberFromChatOptIn` flag **and** a
channel in `DURABLE_CHAT_INGESTION_CHANNELS` (Telegram bot/Mini App, WhatsApp,
Discord, Slack — never push, SMS, email, or a Telegram channel). It returns the
channel's `retentionClass`, a list of channel-specific `redactionRules` (e.g.
`strip-phone-numbers`, `strip-device-tokens`, `strip-subscription-endpoint` for
Web Push), and the provenance tuple `{ channelId, externalMessageId }`.
`consentRevocationCascade`, `crisisCrossChannelSuppression`, and
`tenantResidencyReport` complete the boundary contract; see
[Channel Abstraction, Routing, and Boundaries](./channel-abstraction-and-boundary.md)
and
[Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md)
for the cascade detail.

## The credential→transport seam — fail loud, never fake

`provider-config-env.ts` is the safety property that ties everything together:
`buildMessageProviderConfigFromEnv(env)` includes a channel in the live
`MessageProviderConfig` **only when every credential it needs is present.** A
channel with missing credentials is simply omitted, so
`deliverDispatchedMessage` reports `missing-config` for it rather than silently
faking a send. No secret is ever logged, defaulted, or invented. The env keys
per channel are:

| Channel          | Required env vars                                                                                                     |
| ---------------- | --------------------------------------------------------------------------------------------------------------------- |
| Email (SMTP)     | `OSHUN_MESSAGING_EMAIL_FROM` + `OSHUN_SMTP_HOST` (+ optional `_PORT/_SECURE/_STARTTLS/_USERNAME/_PASSWORD`)           |
| Email (SendGrid) | `OSHUN_MESSAGING_EMAIL_FROM` + `OSHUN_SENDGRID_API_KEY`                                                               |
| SMS              | `OSHUN_TWILIO_ACCOUNT_SID`, `OSHUN_TWILIO_AUTH_TOKEN`, `OSHUN_TWILIO_FROM`                                            |
| Push (FCM)       | `OSHUN_FCM_ACCESS_TOKEN`, `OSHUN_FCM_PROJECT_ID`                                                                      |
| Push (APNs)      | `OSHUN_APNS_TEAM_ID`, `OSHUN_APNS_KEY_ID`, `OSHUN_APNS_PRIVATE_KEY`, `OSHUN_APNS_BUNDLE_ID`                           |
| Push (Web Push)  | `OSHUN_VAPID_PUBLIC_KEY`, `OSHUN_VAPID_PRIVATE_KEY`, `OSHUN_VAPID_SUBJECT`                                            |
| WhatsApp         | `OSHUN_WHATSAPP_ACCESS_TOKEN`, `OSHUN_WHATSAPP_PHONE_NUMBER_ID`, `OSHUN_WHATSAPP_TEMPLATE`, `OSHUN_WHATSAPP_LANGUAGE` |
| Slack            | `OSHUN_SLACK_BOT_TOKEN`                                                                                               |
| Discord          | `OSHUN_DISCORD_BOT_TOKEN`                                                                                             |

`configuredChannelsFromEnv` reports which channels are live in a given
environment, and `TransportFetch` (plus `createHttp2TransportFetch` for APNs and
the `BinaryTransportFetch` for Web Push) is injectable so every transport is
unit-testable without network or credentials. This is also why the
**deliverability monitoring** the prose describes — bounce/complaint rates
(email), delivery/undelivered (SMS), revoked tokens (push), template status
(WhatsApp) — is fed by real webhook-ingestion functions, not synthetic numbers.

## Cross-domain integration: V3 deep links and session reminders

V3 programming wires into these channels through two modules absent from the
narrative docs:

- **`v3-deep-links.ts`** — canonical share links with
  `V3_SHARE_LINK_CANONICAL_HOST = 'app.oshun.com'`, the custom scheme
  `oshun://v3` (`V3_SHARE_LINK_CUSTOM_SCHEME = 'oshun'`,
  `V3_SHARE_LINK_CUSTOM_HOST = 'v3'`, path prefix `/v3`), and the share-channel
  set
  `V3_MESSAGING_SHARE_CHANNELS = ['telegram', 'whatsapp', 'push', 'email', 'sms']`.
  Surfaces include Tara bookings, Arete continuations, tickets, follows, and
  Saraswati editions.
- **`v3-session-reminders.ts`** — binds the V3 calendar to the dispatcher,
  enforcing the launch rule that a reminder may only be delivered inside the
  **30-minute pre-session window**
  (`V3_SESSION_REMINDER_MAX_LEAD_TIME_MINUTES = 30`). It maps each share channel
  to a default channel ID (`telegram → telegram-bot`,
  `whatsapp → whatsapp-business`, `push → push-fcm`, `email → email-ses`,
  `sms → sms-twilio`).

## The customer message center

The prose mentions a "customer-visible message center"; the real substrate is
`@oshun/customer-message-center`
(`customer-message-center/src/message-center.ts`). It defines
`CUSTOMER_MESSAGE_CHANNELS = ['in-app', 'email', 'push', 'sms', 'voice']`, ten
`CUSTOMER_MESSAGE_CATEGORIES`
(`onboarding, milestone, reengagement, billing, privacy, support, incident, content-update, social, system`),
four `CUSTOMER_MESSAGE_PRIORITIES` (`low, normal, high, critical`), and an
eight-stage receipt model
`CUSTOMER_MESSAGE_RECEIPT_STAGES = ['queued', 'sent', 'delivered', 'opened', 'clicked', 'failed', 'bounced', 'suppressed']`.
This is the inbox state model that gives customers a unified view across the
channels above and flows into the BFF
(`apps/oshun/bff/src/routes/customer-message-center.ts`).

## Honest status — what is shipped vs. provider-gated

This area is implemented to an unusually high degree, and nothing in it is a
result-faking stub. The honest edges:

- **The policy core, planners, payload builders, and boundary are real and
  tested.** The dispatcher, the cost ledgers, the WhatsApp session-window logic,
  the SMS pricing/A2P gate, the email DKIM/SPF/DMARC gate, and the entitlement
  asserts for Slack/Discord all run as pure, deterministic, unit-tested code.
- **The transports are real protocol clients** — the SMTP client (RFC 5321) and
  the Web Push transport (RFC 8030/8291/8188/8292, verified against the RFC 8291
  Appendix A KAT) are genuine, dependency-light implementations, not wrappers.
- **WhatsApp and SMS transports are planners whose live provider HTTP calls
  require real credentials.** With no credentials configured, the seam reports
  `missing-config` — the fail-loud, never-fake behavior.
- **Outbound Telegram delivery is the noted gap.** Per the completeness audit,
  the BFF end-to-end outbound Telegram send loop is `unimplemented`; the policy
  core and Sophia grounding are real. The Telegram voice-STT path is now a
  genuine fail-closed seam, not a stub — any doc calling it a stub is stale. See
  [Telegram Surfaces](./telegram-surfaces.md) for that surface in depth.

The backlog for this subsystem is §26 (channels); the feature-hub section lives
in [../features.md](../features.md). Residency and consent obligations are
tracked under deps§26.

## Related

- [Channel Abstraction, Routing, and Boundaries](./channel-abstraction-and-boundary.md)
- [Telegram Surfaces — Bot, Mini Apps, Channels, Inline, Auth, Payments](./telegram-surfaces.md)
- [Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md)
- [Sophia Grounding](./sophia-grounding.md)
- [Iris Memory and Identity](./iris-memory-identity.md)
- [Support, Entitlements, Billing, and Customer Operations](./support-billing.md)
- [../features.md](../features.md)
