> ## 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.

# Send proactive messages

> Message an end user first, from a job, webhook, or tool, on any of the seven outbound channels

After this guide, your agent sends the first message: a reminder from a job, a receipt from a tool. Use `Channels.send` when you choose the [channel](/concepts/channels) and the recipient; use `user.send()` when you hold a [`User`](/reference/sdk/user) and want the channel they last wrote from. Sends to a person are recorded in their conversation.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* The channel linked to the agent ([Channels overview](/channels/overview)).
* For WhatsApp outside the 24-hour window, a WhatsApp message template created and approved in Meta's WhatsApp Manager ([WhatsApp](/channels/whatsapp)).

<Steps>
  <Step title="Pick the channel and the address">
    `Channels.send` accepts seven channel IDs; `userId` works everywhere, a phone number or email address only where first contact is allowed.

    | Channel ID                                                | Address                      | First contact                                          |
    | --------------------------------------------------------- | ---------------------------- | ------------------------------------------------------ |
    | `whatsapp` ([WhatsApp](/channels/whatsapp))               | `userId` or `phoneNumber`    | Yes; free text only inside the 24-hour window          |
    | `sms` ([SMS](/channels/sms-and-phone-numbers))            | `userId` or `phoneNumber`    | Yes                                                    |
    | `email` ([Email](/channels/email))                        | `userId` or `email`          | Yes                                                    |
    | `webchat` ([Web widget](/channels/web-widget/quickstart)) | `userId`                     | After the user writes                                  |
    | `teams` ([Teams](/channels/teams))                        | `userId` or `conversationId` | After the user writes, or a group chat the agent is in |
    | `instagram` ([Instagram](/channels/instagram))            | `userId`                     | After the user writes                                  |
    | `messenger` ([Messenger](/channels/messenger))            | `userId`                     | After the user writes                                  |

    Slack, Front, RCS, and iMessage are inbound only; MessageBird is reachable through `user.send()` alone. `text` may carry `:::` formatting blocks ([Rendering by channel](/channels/formatting/overview#rendering-by-channel)).
  </Step>

  <Step title="Send from a job">
    A [job](/concepts/jobs) has no current user, so it addresses each recipient explicitly; the `appointments` entries come from your booking tool, which stores the Lua user ID with `Data.create('appointments', { userId: user._luaProfile.userId, date, time })` ([Store and search data](/build/store-and-search-data), [Identify users](/build/identify-users)). On WhatsApp, `onClosedWindow: 'fail'` makes the send throw when the 24-hour window has closed, and the `catch` falls back to an approved WhatsApp message template; `messageContext` is recorded as what it said.

    ```ts src/jobs/AppointmentRemindersJob.ts theme={null}
    import { LuaJob, Data, Channels } from 'lua-cli';

    export default new LuaJob({
      name: 'appointment-reminders',
      description: 'Remind everyone with an appointment tomorrow, over WhatsApp',
      schedule: { type: 'cron', expression: '0 9 * * *', timezone: 'Europe/London' },
      async execute() {
        const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
        const due = await Data.get('appointments', { date: tomorrow }, 1, 100);

        for (const appt of due.data) {
          const { userId, time } = appt.data as { userId: string; time: string };
          try {
            await Channels.send({
              channel: 'whatsapp',
              to: { userId },
              text: `Reminder: your appointment is tomorrow at ${time}. Reply here to reschedule.`,
              options: { whatsapp: { onClosedWindow: 'fail' } },
            });
          } catch {
            // The 24-hour window is closed: only an approved template may be sent.
            await Channels.whatsapp.sendTemplate({
              to: { userId },
              templateName: 'appointment_reminder',
              languageCode: 'en_US',
              components: [{ type: 'body', parameters: [{ type: 'text', text: time }] }],
              messageContext: `Reminded the customer about their appointment tomorrow at ${time}`,
            });
          }
        }
        return { reminded: due.data.length };
      },
    });
    ```

    Leave `onClosedWindow` unset and the platform queues the text: it sends an opt-in prompt, returns `queued: true`, and delivers your text when the recipient replies. Meta blocks that prompt for US (+1) numbers (the result carries a `warning`), so send an approved WhatsApp message template to those recipients.
  </Step>

  <Step title="Send from a tool">
    Inside a conversation, `User.get()` returns the end user. `Channels.email.send` takes a `subject` and one of `text`, `html`, or `richBody`. `user.send()` delivers on the channel the end user last wrote from, when that is WhatsApp, Messenger, Instagram, Teams, MessageBird, or SMS, and mirrors the text to the web widget; deployed, it resolves `true` whatever happened, so confirm delivery with `Channels.getStatus` or `lua logs` (under `lua test` it throws instead).

    ```ts src/skills/tools/SendReceiptTool.ts theme={null}
    import { LuaTool, User, Channels } from 'lua-cli';
    import { z } from 'zod';

    export default class SendReceiptTool implements LuaTool {
      name = 'send_receipt';
      description = 'Email the current user a receipt and confirm it in the conversation';
      inputSchema = z.object({
        orderId: z.string(),
        total: z.string().describe('Formatted total, for example "£42.00"'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const user = await User.get();
        if (!user) throw new Error('No user in this conversation');

        const email = await Channels.email.send({
          to: { userId: user._luaProfile.userId },
          subject: `Receipt for order ${input.orderId}`,
          text: `Thanks for your order. Total charged: ${input.total}.`,
        });

        await user.send([{ type: 'text', text: `I've emailed your receipt for order ${input.orderId}.` }]);
        return { deliveryId: email.deliveryId, status: email.status };
      }
    }
    ```
  </Step>

  <Step title="Test locally">
    `lua test` runs the job or tool on your machine; the sends are real. With nothing due, the job returns its empty result.

    ```bash theme={null}
    lua test job --name appointment-reminders --ci
    ```

    ```text Output theme={null}
    ✅ Selected job: appointment-reminders
    …
    Job returned: Object — fields: reminded
    Output:
    { reminded: 0 }
    ```

    `lua test skill` runs `send_receipt` as you, so the receipt goes to your own user record; until you have emailed the agent, the run ends with `email send requires a recipient email, or a userId with an existing email conversation`.

    ```bash theme={null}
    lua test skill --name send_receipt --input '{"orderId":"A-1001","total":"£42.00"}' --ci
    ```
  </Step>

  <Step title="Release it">
    `lua push` uploads a version and changes nothing for end users; `lua version create` snapshots the agent; `lua version promote <n>` makes that snapshot live and is also the rollback path ([Release an agent to production](/ship/releasing)).

    ```bash theme={null}
    lua push all --ci --force
    lua version create --ci -m "Appointment reminders and receipts"
    lua version promote <n>
    ```

    `lua version create` prints `✓ Created v<n> (staged)`; in a script, `n=$(lua version list --limit 1 --json --ci | jq -r '.[0].version')` then `lua version promote "$n"`. `lua push all` exits 0 even when a primitive fails; check its output for `component(s) failed to push`.
  </Step>

  <Step title="Verify">
    Trigger the job once and read the run.

    ```bash theme={null}
    lua jobs trigger -i appointment-reminders
    lua logs --type job --name appointment-reminders --limit 5
    ```

    <Warning>
      `lua jobs trigger` runs the job against production data and messages every recipient it finds.
    </Warning>

    Every send returns a `deliveryId` and a `status`: `queued` or `accepted` at return, then `sent`, `delivered`, `read`, `failed`, or `expired` as receipts arrive. `Channels.getStatus(deliveryId)` reads one record; `Channels.listDeliveries({ status: 'failed', since, limit })` lists failures with category, vendor code, and remediation link ([Check deliveries](#check-deliveries)).
  </Step>
</Steps>

## Options you may need

### Thread an email reply

Pass the inbound email's `Message-ID` as `inReplyTo` and the thread's IDs as `references`; in a tool handling an email turn, it is `Lua.request.webhook.payload.messageId`.

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

export async function replyInThread(userId: string, rootMessageId: string) {
  await Channels.email.send({
    to: { userId },
    subject: 'Re: your ticket',
    text: 'Your technician is booked for Thursday at 09:00.',
    inReplyTo: rootMessageId,
    references: [rootMessageId],
  });
}
```

### Check deliveries

A [webhook](/concepts/webhooks) reads delivery records from outside a conversation.

```ts src/webhooks/DeliveryCheckWebhook.ts theme={null}
import { LuaWebhook, Channels } from 'lua-cli';

export default new LuaWebhook({
  name: 'delivery-check',
  description: 'Report the state of one delivery, or every failure in the last day',
  async execute(event) {
    if (event.query?.deliveryId) {
      const delivery = await Channels.getStatus(String(event.query.deliveryId));
      return { status: delivery.status, error: delivery.error?.title };
    }
    const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
    const failed = await Channels.listDeliveries({ status: 'failed', since, limit: 50 });
    return failed.map((d) => ({ id: d.id, channel: d.channel, category: d.error?.category }));
  },
});
```

## If it isn't working

<AccordionGroup>
  <Accordion title="The result has queued: true and the message hasn't arrived">
    **Cause** The recipient's WhatsApp window is closed; the text waits for their reply to the opt-in prompt. **Fix** For time-sensitive messages, set `onClosedWindow: 'fail'` and send an approved WhatsApp message template.
  </Accordion>

  <Accordion title="The result has persisted: false and a warning">
    **Cause** The message was delivered but not recorded in the conversation, or it went to a Teams `conversationId`, which has no single recipient. **Fix** Nothing is thrown; log the `warning` if continuity matters.
  </Accordion>

  <Accordion title="A send to webchat, teams, instagram, or messenger throws">
    **Cause** Those channels deliver only to an end user who has already written, by `userId`. **Fix** Pass `to: { userId }`; `phoneNumber` and `email` work only on WhatsApp, SMS, and email.
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="About channels" href="/concepts/channels">Inbound and outbound vocabularies.</Card>
  <Card title="Channels reference" href="/reference/sdk/channels">`send`, `whatsapp.sendTemplate`, `email.send`, `getStatus`, and `listDeliveries`.</Card>
  <Card title="WhatsApp" href="/channels/whatsapp">Link the channel and get a message template approved.</Card>
  <Card title="Schedule a recurring job" href="/build/schedule-a-job">The job that carries your outbound messages.</Card>
</Columns>
