Elements

Channel

Reaching humans — email, SMS, WhatsApp, and push with consent, locale, receipts, and fallback chains built in.

Channel is how your app reaches humans: the order-confirmation email, the OTP text, the WhatsApp notification. Reaching a person is not the same problem as moving data between machines — it needs consent (did they opt out?), locale (which language?), receipts (did it land?), and fallback (email failed, try SMS). Those physics are built into the element, so every flow gets them for free.

The one rule

Sends go through fx.send and declared templates — never through a raw SMTP or provider client inside a flow. That is what makes consent, locale, and receipts unavoidable rather than optional.

Quick start

Declare a medium and a template

A medium binder carries your defaults; a template names the message and types its data:

src/channels.ts
import { channel } from "okengine";
import { z } from "zod";

export const mail = channel.email({ from: "Provisions <no-reply@provisions.sa>" });

export const orderConfirmed = mail.template("order-confirmed", {
  schema: z.object({ name: z.string(), orderId: z.string(), total: z.number() }),
  locales: ["en", "ar"],
});

Send it from a Flow

One call — consent, locale resolution, and the receipt happen around it:

src/flows/orders/confirm.ts
do: async (input, fx) => {
  await fx.send(orderConfirmed, {
    to: input.email,
    data: { name: input.name, orderId: input.id, total: input.total },
  });
};

Read it in development

Locally the console driver captures mail into an inbox instead of sending; in docker mode the stack runs Mailpit, a real SMTP catcher with a web UI — so you see the exact rendered message without ever touching a real mailbox.

Mediums and templates

DeclarationProduces
channel.email({ from })Email binder (default sender)
channel.sms({ sender })SMS binder (sender id)
channel.whatsapp()WhatsApp binder
channel.push()Push binder
binder.template(name, opts)Typed template bound to that medium
channel.template(name, opts)Medium-agnostic template (one body, any medium)
Template optionTypeMeaning
schemazod / Standard SchemaThe data the template may reference — typed sends
localesstring[]Languages this template is rendered in

The human physics

Opt-out is first-class: a subject who opted out of a medium is suppressed — the send resolves without contacting the provider, and the receipt says so. You never hand-roll "did they unsubscribe?" checks.

Locale resolves through a chain

Templates render per recipient locale, falling back through your configured chain (ar → default en) instead of failing when a translation is missing. Locales and the default come from the i18n block in oke.config.ts.

Fallback chains are explicit

via orders the mediums to try — first success wins, and every attempt is recorded:

await fx.send(otpCode, { to: user.phone, data: { code }, via: [sms, wa] });
// sms fails → whatsapp tried → receipt status "fallback" with both attempts

Receipts for everything

Each send records its attempts — driver, ok/error, timestamp, message id — so "did the user actually get it?" is a Console query, not a guess. In a dry run, sends are recorded as would have fired and never contact a real provider.

Per-environment drivers

oke.config.ts
drivers: {
  channel: {
    email: { local: "console", docker: "smtp", test: "console", prod: "smtp" },
  },
},
images: {
  "channel.email": "axllent/mailpit:v1.22.3", // SMTP catcher for the docker stack
},
DriverMediumBehavior
consoleanyCaptures into a readable inbox — local + tests
smtpemailReal SMTP — Mailpit in docker, your provider in prod
resendemailResend API
unifonicsmsUnifonic SMS API

Troubleshooting

Learn more

  • Flowfx.send inside do
  • Console · Channels — receipts, attempts, suppression
  • Signal — machine-to-machine messaging, the other side of the line

Next

On this page