> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Channels

> Outbound messages, WhatsApp templates and reactions, email, and delivery receipts from runtime code

`Channels` sends messages to end users on the [channels](/concepts/channels) the agent is connected to and reads the delivery record each send creates. A send addressed to a person is recorded in that person's conversation with the agent, so the agent has the context when they reply. Available in tools, jobs, webhooks, triggers, processors, and workflow code steps. For the rules per channel, including the WhatsApp 24-hour window, see [Send proactive messages](/build/send-proactive-messages).

*Verified against lua-cli 3.33.0.*

```ts theme={null}
import { Channels, CHANNEL_SEND_CHANNELS } from 'lua-cli';
```

## Quick example

A send returns a delivery id; `getStatus` reads the receipts that arrive later.

```ts theme={null}
import { Channels } from 'lua-cli';

const sent = await Channels.send({
  channel: 'whatsapp',
  to: { phoneNumber: '+15551234567' },
  text: 'Your order has shipped.',
});

const delivery = await Channels.getStatus(sent.deliveryId);
```

## Methods

### send(input)

Sends a text message on one channel.

```ts theme={null}
Channels.send(input: ChannelSendInput): Promise<ChannelSendOutput>
```

<ParamField path="channel" type="ChannelSendChannel" required>
  One of `CHANNEL_SEND_CHANNELS`: `whatsapp`, `sms`, `email`, `webchat`, `teams`, `instagram`, `messenger`. These are outbound ids; the inbound value in `Lua.request.channel` is `pop` for the widget (the `Channel` type spells it `web`) and `facebook` for Messenger.
</ParamField>

<ParamField path="to" type="ChannelSendTarget" required>
  Exactly one of `userId` (any channel), `phoneNumber` (`whatsapp` and `sms`, E.164), `email` (`email`), or `conversationId` (`teams` only: a group chat or channel the agent is already in; everyone in it receives the message). `teams`, `instagram`, and `messenger` need a prior inbound conversation, so they accept `userId` or `conversationId` only; `webchat` accepts `userId` only.
</ParamField>

<ParamField path="text" type="string" required>
  The message. `:::` [formatting components](/channels/formatting/overview) render the way they do in replies.
</ParamField>

<ParamField path="options.channelIdentifier" type="string">
  Pins the send to one channel configuration when the agent has several of the same type. It must belong to the agent.
</ParamField>

<ParamField path="options.whatsapp.onClosedWindow" type="'queue' | 'fail'" default="queue">
  What to do when the WhatsApp 24-hour window is closed. `queue` sends the platform's message-request template, holds the text, and delivers it when the end user replies; `fail` rejects the send so you can send your own approved WhatsApp message template with `whatsapp.sendTemplate`.
</ParamField>

**Returns**

<ResponseField name="result" type="ChannelSendOutput">
  <Expandable title="properties">
    <ResponseField name="deliveryId" type="string">
      The delivery record's id, stable for the message's whole life. Pass it to `getStatus`.
    </ResponseField>

    <ResponseField name="status" type="DeliveryStatus">
      Where the delivery stands as the call returns: `accepted`, or `queued` for a WhatsApp text held behind a closed window. Receipts move it on afterwards.
    </ResponseField>

    <ResponseField name="delivered" type="boolean">
      The channel accepted the message.
    </ResponseField>

    <ResponseField name="persisted" type="boolean">
      The message was recorded in the recipient's conversation. A delivered message whose record failed returns `persisted: false` plus a `warning`; it doesn't throw. Sends to a `conversationId` are never persisted.
    </ResponseField>

    <ResponseField name="queued" type="boolean">
      WhatsApp only. `true` when the window was closed and the text is held until the end user replies; `delivered` stays `false` until then.
    </ResponseField>

    <ResponseField name="userId" type="string">
      The end user the message was recorded against.
    </ResponseField>

    <ResponseField name="identifier" type="string">
      The channel-native address used: phone number, email address, or conversation id.
    </ResponseField>

    <ResponseField name="messageId" type="string">
      The provider's message id, when the channel returns one.
    </ResponseField>

    <ResponseField name="warning" type="string">
      Set when `persisted` is `false`.
    </ResponseField>
  </Expandable>
</ResponseField>

**Example**

```ts theme={null}
import { LuaJob, Data, Channels } from 'lua-cli';

export default new LuaJob({
  name: 'renewal-reminders',
  description: 'Remind customers whose plan renews tomorrow',
  schedule: { type: 'cron', expression: '0 9 * * *', timezone: 'Europe/London' },
  async execute() {
    const due = await Data.get('renewals', { status: 'due-tomorrow' }, 1, 100);
    for (const renewal of due.data) {
      await Channels.send({
        channel: 'sms',
        to: { phoneNumber: renewal.data.phone },
        text: 'Your plan renews tomorrow. Reply STOP to cancel.',
      });
    }
    return { sent: due.data.length };
  },
});
```

**Errors** — the call throws when the send is rejected. In a deployed agent the error is a `ChannelSendError` with `code` and `statusCode`; in `lua test` it is a plain `Error` carrying the server's message, or `Channel send failed`.

* `Unknown channel: <channel>`
* `to must have exactly one of userId, phoneNumber, email, or conversationId`
* `conversationId is only supported on teams`
* `<channel> is a warm-only channel; address by userId or conversationId`
* `webchat channel requires to.userId`
* `phoneNumber cold-start not valid for <channel>; use userId`
* A closed WhatsApp window with `onClosedWindow: 'fail'`, or a provider rejection.

### whatsapp.sendTemplate(input)

Sends an approved [WhatsApp message template](/channels/whatsapp) to one person, inside or outside the 24-hour window.

```ts theme={null}
Channels.whatsapp.sendTemplate(input: WhatsAppTemplateSendInput): Promise<ChannelSendOutput>
```

<ParamField path="to" type="{ userId?: string; phoneNumber?: string }" required>
  Exactly one of `userId` or `phoneNumber` (E.164).
</ParamField>

<ParamField path="templateName" type="string" required>
  The name of an approved WhatsApp message template on the agent's WhatsApp channel. List them with [`Templates.whatsapp.list`](/reference/sdk/templates).
</ParamField>

<ParamField path="languageCode" type="string">
  The template's language, for example `en_US`.
</ParamField>

<ParamField path="components" type="Array<Record<string, unknown>>">
  Meta component objects with the parameter values, for example `{ type: 'body', parameters: [{ type: 'text', text: 'ORD-99' }] }`.
</ParamField>

<ParamField path="messageContext" type="string">
  Plain text recorded in the conversation as what the template said, so the agent remembers the outreach. Recommended whenever the template body isn't self-explanatory.
</ParamField>

<ParamField path="options" type="ChannelSendOptions">
  As on `send`.
</ParamField>

**Returns** — a `ChannelSendOutput`.

**Example**

```ts theme={null}
import { Channels } from 'lua-cli';

await Channels.whatsapp.sendTemplate({
  to: { phoneNumber: '+15551234567' },
  templateName: 'appointment_reminder',
  languageCode: 'en_US',
  components: [{ type: 'body', parameters: [{ type: 'text', text: 'Tuesday at 3pm' }] }],
  messageContext: 'Reminded the customer about their Tuesday 3pm appointment',
});
```

**Errors** — `to must provide userId or phoneNumber`, `phoneNumber must contain digits`, a template that isn't approved, or a provider rejection; thrown as on `send`.

### whatsapp.sendReaction(input)

Reacts to a WhatsApp message with one emoji, the way a person does by long-pressing it.

```ts theme={null}
Channels.whatsapp.sendReaction(input: WhatsAppReactionSendInput): Promise<ChannelSendOutput>
```

<ParamField path="to" type="{ userId?: string; phoneNumber?: string }" required>
  Exactly one of `userId` or `phoneNumber` (E.164).
</ParamField>

<ParamField path="messageId" type="string" required>
  The provider id of the message to react to, a `wamid…` value from the conversation history or the channel payload. Meta accepts messages up to 30 days old.
</ParamField>

<ParamField path="emoji" type="string" required>
  One emoji. An empty string removes the agent's existing reaction.
</ParamField>

<ParamField path="options" type="ChannelSendOptions">
  As on `send`.
</ParamField>

**Returns** — a `ChannelSendOutput`.

**Example**

```ts theme={null}
import { Channels } from 'lua-cli';

await Channels.whatsapp.sendReaction({
  to: { userId: 'user_abc123' },
  messageId: 'wamid.HBgLMTU1NTU1NTU1NTUVAgARGBI5QTNDQTVCM0Q0RUQ5RTU3RgA=',
  emoji: '👍',
});
```

**Errors** — `messageId is required`, `emoji is required (an empty string removes the reaction)`, `to must provide userId or phoneNumber`; thrown as on `send`.

### email.send(input)

Sends an email with a subject, a plain-text, HTML, or rendered body, copies, attachments, and threading headers. The agent must have an email channel linked.

```ts theme={null}
Channels.email.send(input: EmailSendInput): Promise<ChannelSendOutput>
```

<ParamField path="to" type="{ userId?: string; email?: string }" required>
  One of `userId` (resolved to the address from the end user's history) or `email`.
</ParamField>

<ParamField path="subject" type="string">
  The subject line.
</ParamField>

<ParamField path="text" type="string">
  Plain-text body, sent as is. Provide one of `text`, `html`, or `richBody`.
</ParamField>

<ParamField path="html" type="string">
  Exact HTML body, sent as is with no template around it.
</ParamField>

<ParamField path="richBody" type="string">
  Markdown and `:::` components, rendered on the server into the branded email template that the agent's own replies use.
</ParamField>

<ParamField path="cc" type="string[]">
  Copy recipients.
</ParamField>

<ParamField path="bcc" type="string[]">
  Blind-copy recipients.
</ParamField>

<ParamField path="attachments" type="Array<{ filename: string; contentType: string; url: string }>">
  Files fetched from `url` at send time and attached. The combined size is limited to 28 MB.
</ParamField>

<ParamField path="inReplyTo" type="string">
  The `Message-ID` of the email this one answers; sets `In-Reply-To` so mail clients thread it. The inbound email's id is `Lua.request.webhook.payload.messageId`. Angle brackets are optional. Honored on the branded (existing-address) email channel.
</ParamField>

<ParamField path="references" type="string[]">
  The thread's accumulated `Message-ID`s for the `References` header.
</ParamField>

<ParamField path="options" type="ChannelSendOptions">
  As on `send`.
</ParamField>

**Returns** — a `ChannelSendOutput`; `messageId` is the provider's message id.

**Example**

```ts theme={null}
import { Channels } from 'lua-cli';

const rootMessageId = 'CAF1234.ticket-184@mail.example.com';
await Channels.email.send({
  to: { email: 'customer@example.com' },
  subject: 'Re: Porch light (MT-2026-184)',
  richBody: 'Your technician is booked for **Thursday at 09:00**.',
  inReplyTo: rootMessageId,
  references: [rootMessageId],
  attachments: [
    { filename: 'work-order.pdf', contentType: 'application/pdf', url: 'https://example.com/work-order.pdf' },
  ],
});
```

**Errors** — `No email channel configuration found. Ensure the agent has an email channel linked.` when the agent has no email channel, `email send requires to.email or to.userId`, `email send requires a text, html, or richBody body`, `Attachments exceed the 28MB limit`; thrown as on `send`.

### getStatus(deliveryId)

Reads one delivery record, including receipts that arrived after the send.

```ts theme={null}
Channels.getStatus(deliveryId: string): Promise<DeliveryView>
```

<ParamField path="deliveryId" type="string" required>
  The id a send returned.
</ParamField>

**Returns** — a `DeliveryView`. `status` only moves forward through `queued`, `accepted`, `sent`, `delivered`, `read`; `failed` is terminal and carries `error`; `expired` is a queued WhatsApp message the end user never replied to. A late or duplicate receipt leaves the record where it is, so polling is safe.

**Example**

```ts theme={null}
import { Channels } from 'lua-cli';

const sent = await Channels.send({ channel: 'whatsapp', to: { phoneNumber: '+15551234567' }, text: 'Your order has shipped.' });
const delivery = await Channels.getStatus(sent.deliveryId);
if (delivery.status === 'failed' && delivery.error?.category === 'window_closed') {
  await Channels.whatsapp.sendTemplate({ to: { phoneNumber: delivery.recipient }, templateName: 'order_update' });
}
```

**Errors** — throws when no delivery with that id belongs to the agent.

### listDeliveries(filter?)

Lists the agent's deliveries, newest first.

```ts theme={null}
Channels.listDeliveries(filter?: DeliveryListFilter): Promise<DeliveryView[]>
```

<ParamField path="filter.userId" type="string">
  Only deliveries addressed to this end user.
</ParamField>

<ParamField path="filter.status" type="DeliveryStatus">
  Only deliveries in this status.
</ParamField>

<ParamField path="filter.channel" type="string">
  A channel name such as `whatsapp`, `email`, or `sms`.
</ParamField>

<ParamField path="filter.since" type="string | Date">
  Only deliveries created at or after this instant.
</ParamField>

<ParamField path="filter.limit" type="number" default={50}>
  From 1 to 200.
</ParamField>

**Returns** — an array of `DeliveryView`.

**Example**

```ts theme={null}
import { Channels } from 'lua-cli';

const failures = await Channels.listDeliveries({
  status: 'failed',
  since: new Date(Date.now() - 24 * 60 * 60 * 1000),
  limit: 100,
});
const billing = failures.filter((d) => d.error?.category === 'billing');
```

**Errors** — `Delivery list failed` when the platform gives no message.

## Retries and idempotency

Every send endpoint accepts an `X-Idempotency-Key` header. In `lua test` the SDK adds a fresh key to each `Channels` call, so a transport retry inside one call can't send twice. In a deployed agent the call is made in process with no key, so a retry of your own, such as a job that runs again after crashing, sends again. To deduplicate across runs, call the [REST send endpoints](/reference/rest/channels) with a key you derive from the thing you are messaging about. Replaying a key returns the first send's outcome with the same `deliveryId` for the life of the delivery record, 180 days; a key whose first attempt failed replays the failure; keys are scoped to one agent.

## Types

<ResponseField name="CHANNEL_SEND_CHANNELS" type="readonly string[]">
  `['whatsapp', 'sms', 'email', 'webchat', 'teams', 'instagram', 'messenger']`. `ChannelSendChannel` is its element type.
</ResponseField>

<ResponseField name="ChannelSendInput" type="interface">
  `channel`, `to`, `text`, `options?`, as on `send`. `ChannelSendTarget` and `ChannelSendOptions` are the shapes of `to` and `options`.
</ResponseField>

<ResponseField name="ChannelSendOutput" type="interface">
  The send result described under `send`.
</ResponseField>

<ResponseField name="WhatsAppTemplateSendInput, WhatsAppReactionSendInput, EmailSendInput" type="interface">
  The inputs of `whatsapp.sendTemplate`, `whatsapp.sendReaction`, and `email.send`.
</ResponseField>

<ResponseField name="DeliveryListFilter" type="interface">
  The filter of `listDeliveries`.
</ResponseField>

<ResponseField name="DeliveryStatus" type="union">
  `queued` (WhatsApp, held until the end user replies), `accepted` (the provider took it and returned an id), `sent` (handed to the recipient's network; where a channel has no receipts, a successful send stops here), `delivered`, `read` (WhatsApp, with read receipts on), `failed`, `expired`.
</ResponseField>

<ResponseField name="DeliveryView" type="interface">
  <Expandable title="properties">
    <ResponseField name="id" type="string">The delivery id.</ResponseField>
    <ResponseField name="agentId" type="string">The sending agent.</ResponseField>
    <ResponseField name="userId" type="string">The end user, when the message was recorded against one.</ResponseField>
    <ResponseField name="channel" type="string">The channel name.</ResponseField>
    <ResponseField name="provider" type="string">One of `meta`, `vonage`, `bird`, `ses`, `agentmail`, `slack`, `front`, `teams`, `pusher`.</ResponseField>
    <ResponseField name="channelIdentifier" type="string">The channel configuration that sent it.</ResponseField>
    <ResponseField name="recipient" type="string">The channel-native address.</ResponseField>
    <ResponseField name="providerMessageId" type="string">The provider's id, when returned.</ResponseField>
    <ResponseField name="conversationMessageId" type="string">The id of the recorded conversation message.</ResponseField>
    <ResponseField name="origin" type="string">What sent it: `agent_reply`, `channels_send`, `template`, `system_template`, `queued_flush`, `admin_template`, `notification`, or `unknown`.</ResponseField>
    <ResponseField name="templateName" type="string">The WhatsApp message template, for template sends.</ResponseField>
    <ResponseField name="idempotencyKey" type="string">The key the send carried, if any.</ResponseField>
    <ResponseField name="status" type="DeliveryStatus">The current status.</ResponseField>
    <ResponseField name="error" type="DeliveryError">Set once `status` is `failed`.</ResponseField>
    <ResponseField name="events" type="Array<{ status, at, source }>">Every status change, with `source` one of `send`, `callback`, `sweep`.</ResponseField>
    <ResponseField name="pricing" type="{ billable, category?, model? }">Provider cost metadata; Meta only.</ResponseField>
    <ResponseField name="createdAt, updatedAt, deliveredAt, readAt, failedAt" type="string">ISO 8601 timestamps; the last three when reached.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="DeliveryError" type="interface">
  `category` (a `DeliveryErrorCategory`), `provider`, `code` (the vendor's own code, as a string), `title`, `detail?`, `href?` (a remediation link), `retryable`, and `owner` (`customer`, `recipient`, `lua`, or `vendor`: who has to act).
</ResponseField>

<ResponseField name="DeliveryErrorCategory" type="union">
  The same vocabulary for every provider.
</ResponseField>

| Category          | Meaning                                                       |
| ----------------- | ------------------------------------------------------------- |
| `window_closed`   | Outside the channel's free-form window; send a template       |
| `unreachable`     | No such recipient, or the device can't be reached             |
| `billing`         | The account can't pay for the message                         |
| `opted_out`       | The recipient blocked or unsubscribed                         |
| `throttled`       | Rate limited; retryable                                       |
| `auth`            | The channel's credentials were rejected or expired            |
| `template`        | The template is unapproved, missing, or wrongly parameterized |
| `media`           | The attachment was rejected or couldn't be fetched            |
| `invalid_request` | The request was malformed                                     |
| `compliance`      | Blocked by policy or content rules                            |
| `experiment`      | Held back by a provider-side experiment                       |
| `provider`        | The provider failed on its side; often retryable              |
| `unknown`         | A code the platform doesn't classify yet                      |

## See also

* [Send proactive messages](/build/send-proactive-messages) — windows, WhatsApp message templates, and when to use `User.send`
* [`Templates`](/reference/sdk/templates) — list WhatsApp message templates and batch-send by phone number
* [`Team`](/reference/sdk/team) — resolve a colleague's shared handles before sending
* [REST channels API](/reference/rest/channels) — the same sends with `X-Idempotency-Key`
* [About channels](/concepts/channels) — the inbound and outbound channel vocabularies
