---
title: "Overview"
description: "Reaching humans — declare email, SMS, WhatsApp, and push templates, send through fx.send, track receipts and consent."
icon: "Mail"
source: "docs/spec/unified-theory.md"
---

Channel is how your backend **reaches a person** — the order-confirmation email, the SMS
sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium with
per-locale `{{field}}` bodies, then send from a Flow with `fx.send`.

For developers wiring Mailpit locally and Resend / Taqnyat / FCM in production — templates and
drivers first, never vendor SDKs inside `do`.

<Callout title="The one rule">
  Declare a template (`channel.email(…).template({ catalog })`), then `fx.send(template, { to, data })`.
  Bodies live on the declare (`subject` / `text` / `html` per locale). Consent and prior bounces
  suppress before any driver runs.
</Callout>

<ChannelPhysics />

## Smallest Example

<Steps>

<Step>
### Declare an email template

```typescript title="src/core/email.ts"
import { channel } from "okengine";
import { z } from "zod";

const mail = channel.email({ from: "Shorter <shorter@localhost>" });

export const linkCreatedMail = mail.template("link-created", {
  description: "Short URL delivered to the demo inbox",
  locales: ["en"],
  schema: z.object({
    id: z.string(),
    code: z.string(),
    url: z.string(),
    shortUrl: z.string(),
  }),
  catalog: {
    en: {
      subject: "Short link {{code}}",
      text: "{{shortUrl}} → {{url}}",
      html: '<p><a href="{{shortUrl}}">{{code}}</a> → {{url}}</p>',
    },
  },
});
```

Import this module before `oke()` so auto-registry adopts the template (or pass it in
`oke({ channel: { templates: […] } })`).

</Step>

<Step>
### Send from a Flow

```typescript title="src/flows/links/signals.ts"
import { on, flow, signal } from "okengine";
import { z } from "zod";
import { linkCreatedMail } from "@/core";

export const linkCreated = signal.once("link-created", {
  retries: 3,
  deadLetter: true,
  schema: z.object({
    id: z.string(),
    code: z.string(),
    url: z.string(),
    shortUrl: z.string(),
  }),
});

export const onCreated = on(
  linkCreated,
  flow("links.onCreated", {
    do: async (payload, fx) => {
      await fx.send(linkCreatedMail, {
        to: "you@localhost",
        data: {
          id: payload.id,
          code: payload.code,
          url: payload.url,
          shortUrl: payload.shortUrl,
        },
      });
    },
  }),
);
```

The compiler stamps `sends: ["link-created"]` on the Flow’s effects (template name — not
`email:link-created`). Declaration and subscriber share `signals.ts`.

</Step>

<Step>
### See it locally

With `drivers.channel.email.dev: "smtp"` and Mailpit pinned, open the Mailpit UI
(`MAILPIT_UI_URL`). Subject and HTML come from the template `catalog`. Omit `catalog` and the
runtime falls back to `subject: link-created` and `text: JSON.stringify(data)`.

</Step>

</Steps>

## Progressive Patterns

From a bare send to catalog bodies, locale, and same-medium failover:

<Tabs items={["Minimal", "Catalog", "Locale", "via"]}>

<Tab value="Minimal">

Template handle + recipient — catalog optional for local smoke tests:

```typescript
await fx.send(linkCreatedMail, {
  to: "alice@example.com",
  data: {
    id: "lnk_1",
    code: "ok",
    url: "https://example.com",
    shortUrl: "http://localhost:6530/ok",
  },
});
```

</Tab>

<Tab value="Catalog">

Bodies are `{{field}}` strings per locale — not ICU, not React. Put them on the template.
`oke({ channel.catalog })` and plugin `.channelCatalog(…)` are overlays (later locale wins):

```typescript title="src/core/email.ts"
export const linkCreatedMail = mail.template("link-created", {
  locales: ["en"],
  schema: z.object({
    code: z.string(),
    url: z.string(),
    shortUrl: z.string(),
  }),
  catalog: {
    en: {
      subject: "Short link {{code}}",
      text: "{{shortUrl}} → {{url}}",
      html: '<p><a href="{{shortUrl}}">{{code}}</a> → {{url}}</p>',
    },
  },
});
```

</Tab>

<Tab value="Locale">

Precedence: explicit `locale` → `profileLocale` → `Accept-Language` →
`channel.defaultLocale` / `i18n.default` (`"en"`). Omit send locale opts and the send uses
`fx.locale`.

```typescript
await fx.send(linkCreatedMail, {
  to: "alice@example.com",
  data: {
    id: "lnk_1",
    code: "ok",
    url: "https://example.com",
    shortUrl: "http://localhost:6530/ok",
  },
  locale: "ar",
});
```

Missing exact locale falls back to default / `en` in the catalog. Chain steps are recorded on
the receipt (`profile:…` · `accept-language:…` · `default:…`).

</Tab>

<Tab value="via">

Order same-medium drivers for failover. Provider / 5xx errors advance; permanent client errors
(400, invalid address) do **not**:

```typescript
await fx.send(linkCreatedMail, {
  to: "alice@example.com",
  data: {
    id: "lnk_1",
    code: "ok",
    url: "https://example.com",
    shortUrl: "http://localhost:6530/ok",
  },
  via: ["smtp", "resend"],
});
```

**Consequence:** receipt status is `fallback` when an earlier attempt failed and a later one
succeeded — every attempt is kept on the receipt.

</Tab>

</Tabs>

## Declaration Reference

| Declaration        | Signature                          | Purpose                                   |
| ------------------ | ---------------------------------- | ----------------------------------------- |
| `channel.email`    | `channel.email(options?)`          | Email medium binder                       |
| `channel.sms`      | `channel.sms(options?)`            | SMS medium binder                         |
| `channel.whatsapp` | `channel.whatsapp(options?)`       | WhatsApp medium binder                    |
| `channel.push`     | `channel.push(options?)`           | Push medium binder                        |
| `binder.template`  | `binder.template(name, options?)`  | Auto-registered template                  |
| `channel.template` | `channel.template(name, options?)` | Medium-agnostic — **not** auto-registered |

### Medium options

| Option   | Type     | Default | Meaning                                     |
| -------- | -------- | ------- | ------------------------------------------- |
| `from`   | `string` | —       | Default sender (email From / SMS sender id) |
| `sender` | `string` | —       | Alias stored as `from`                      |

### Template options

| Option        | Type       | Default   | Meaning                                             |
| ------------- | ---------- | --------- | --------------------------------------------------- |
| `description` | `string`   | name      | Console / docs label                                |
| `locales`     | `string[]` | —         | Declared locale tags for the template               |
| `schema`      | Schema     | —         | Payload shape (Zod / Standard Schema)               |
| `catalog`     | object     | —         | Per-locale `subject` / `text` / `html`              |
| `from`        | `string`   | binder’s  | Override sender on agnostic `channel.template` only |
| `medium`      | medium     | `"email"` | Only on `channel.template()`                        |

Empty name throws `TypeError: channel.template: name is required`.

`oke({ channel.catalog })` overlays bodies after template catalogs (tests / plugin copy
overrides). Manifest stays free of copy.

## `fx` surface

| Method                     | Capability / `sends` | Meaning                                     |
| -------------------------- | -------------------- | ------------------------------------------- |
| `fx.send(template, opts?)` | template name        | Deliver through the medium’s driver chain   |
| `fx.sendOtp(opts)`         | `"sms-otp"`          | Provider-managed SMS OTP.                   |
| `fx.verifyOtp(opts)`       | `"sms-otp"`          | Check a provider OTP code                   |
| `fx.deliverOtp(opts)`      | `"auth-otp"`         | App-owned OTP across email / SMS / WhatsApp |

### `fx.send` options

| Option           | Type     | Meaning                                      |
| ---------------- | -------- | -------------------------------------------- |
| `to`             | `string` | Recipient (email / E.164 / FCM token / …)    |
| `data`           | object   | Interpolated into `{{field}}` catalog bodies |
| `via`            | refs     | Same-medium driver order for failover        |
| `locale`         | `string` | Explicit locale (wins)                       |
| `profileLocale`  | `string` | Profile locale step                          |
| `acceptLanguage` | `string` | Raw `Accept-Language` header                 |

Dry-run records _would have fired_ and never contacts a provider. Undeclared send → **OKE1004**
`UNDECLARED_SEND`.

## Per-environment drivers

Email defaults from `DRIVER_DEFAULTS.channel.email`. SMS / WhatsApp / push are **opt-in**:

```typescript title="oke.config.ts"
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    channel: {
      email: { dev: "smtp", test: "console", prod: "smtp" },
      // sms: { prod: "taqnyat" },
      // whatsapp: { prod: "wa-cloud" },
    },
  },
  images: {
    channel: { email: "axllent/mailpit:v1.31.2" },
  },
});
```

| Key                | Default (dev / test / prod) | Driver ids                                                       |
| ------------------ | --------------------------- | ---------------------------------------------------------------- |
| `channel.email`    | `smtp` / `console` / `smtp` | `console` · `smtp` · `resend` · `sndr` · `taqnyat-mail`          |
| `channel.sms`      | none                        | `taqnyat` · `msegat` · `unifonic` (`console` opens nothing)      |
| `channel.whatsapp` | none                        | `wa-cloud` · `taqnyat-whatsapp`                                  |
| `channel.push`     | none — **not auto-bound**   | Pass `oke({ channel: { drivers: […] } })` with `webpush` / `fcm` |

Env knobs: [Environment variables](/docs/reference/environment-variables). Local SMTP catcher:
[Mailpit](/docs/recipes/mailpit).

## The Capabilities of Channel

<Cards>
  <Card
    title="Email"
    description="SMTP / Mailpit, Resend, SNDR, Taqnyat Mail — templates and catalogs."
    href="/docs/elements/channel/email"
  />
  <Card
    title="SMS"
    description="Transactional SMS and provider-managed OTP (Taqnyat Verify)."
    href="/docs/elements/channel/sms"
  />
  <Card
    title="WhatsApp"
    description="wa-cloud and Taqnyat WhatsApp medium binders."
    href="/docs/elements/channel/whatsapp"
  />
  <Card
    title="Push"
    description="FCM device tokens and Web Push (VAPID) driver binding."
    href="/docs/elements/channel/push"
  />
  <Card
    title="Receipts"
    description="In-memory ledger, outcomes taxonomy, consent and bounce suppression."
    href="/docs/elements/channel/receipts"
  />
</Cards>

## Troubleshooting

<Accordions>

<Accordion title='channel: unknown template "…"'>
  `fx.send` named a template that was never declared or not adopted at boot. Import the medium
  binder module before `oke()`, or pass `channel.templates` explicitly.
</Accordion>

<Accordion title="OKE1004 — UNDECLARED_SEND">
  Cause: `Flow "{flow}" sends "{resource}" without declaring it.` Touch the template handle
  inside `do` (inference) or list `effects: { sends: ["link-created"] }`.
</Accordion>

<Accordion title="channel: no email transport in driver chain">
  No email-capable driver is bound (or `via` filtered them all out). Check `drivers.channel.email`
  and `SMTP_URL` / provider API keys for the active env.
</Accordion>

<Accordion title="oke boot: smtp driver needs SMTP_URL">
  Email is pinned to `smtp` but neither `SMTP_URL` nor `OKE_CHANNEL_EMAIL_URL` is set. For local
  Docker, run the Mailpit stack so compose writes the URL.
</Accordion>

<Accordion title="Process-local suppression / receipts warning">
  Boot warns: `Channel suppression/consent/receipts default to process-local memory…` That is
  `test`, or a boot with no `DATABASE_URL`. Otherwise consent, suppression, and receipts are the
  Postgres ledger. See [Receipts](/docs/elements/channel/receipts).
</Accordion>

</Accordions>

## Learn more

- [Email](/docs/elements/channel/email) — drivers, catalog, Mailpit
- [SMS](/docs/elements/channel/sms) — `fx.sendOtp` / `fx.verifyOtp`
- [WhatsApp](/docs/elements/channel/whatsapp) — `channel.whatsapp` + boot drivers
- [Push](/docs/elements/channel/push) — FCM / Web Push binding
- [Receipts](/docs/elements/channel/receipts) — ledger, outcomes, suppression
- [OTP plugin](/docs/plugins/otp) — `/auth/otp/*` over Channel
- [fx](/docs/reference/fx) — `fx.send` options
- [i18n](/docs/reference/i18n) — Channel catalogs vs `fx.t`

## Next

<Cards>
  <Card
    title="Email"
    description="Declare email templates and pin SMTP / Resend drivers."
    href="/docs/elements/channel/email"
  />
  <Card title="AI Element" description="Models, prompts, and agents." href="/docs/elements/ai" />
  <Card
    title="The Model"
    description="Eight elements overview."
    href="/docs/understand/the-architecture"
  />
</Cards>
