> ## 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 eight 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 eight 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` ([Slack](/channels/slack)) | `userId`, `email`, or `conversationId` | Yes, in a channel the bot is in, a thread, or a DM to a workspace member |

    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],
  });
}
```

### Post in Slack

Slack sends go through the agent's own Slack bot. During a Slack channel-thread turn, `Lua.request.conversation.externalId` is that thread's key (`T…:A…:C…:threadTs`); a tool can store it with `Data.create('report-threads', { conversationId })`, and a job posts into the thread later. `to: { email }` DMs the workspace member with that address.

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

export default new LuaJob({
  name: 'nightly-report',
  description: 'Post the overnight result in every Slack thread that asked for it, and DM the owner',
  schedule: { type: 'cron', expression: '0 7 * * *', timezone: 'Europe/London' },
  async execute() {
    const threads = await Data.get('report-threads', {}, 1, 100);
    for (const entry of threads.data) {
      const { conversationId } = entry.data as { conversationId: string };
      await Channels.send({
        channel: 'slack',
        to: { conversationId },
        text: 'The overnight load finished with 3 warnings.',
      });
    }
    await Channels.send({ channel: 'slack', to: { email: 'pat@example.com' }, text: 'Your overnight report is ready.' });
    return { posted: threads.data.length };
  },
});
```

The bot must be a member of the channel. Threads in one channel share that channel's limit of about one post per second, so space the posts out, or catch the `503` and retry after its `retryAfterSeconds`. A post in a thread where the bot has already been mentioned is recorded there, so the next mention knows what the agent posted. Every address form, the rules on which bot posts, and the errors are in [Post from code](/channels/slack#post-from-code).

### 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, or it went to a Teams or Slack `conversationId` the agent has no shared conversation for yet: a Teams chat it has not been added to, or a Slack thread or group DM where nobody has mentioned the bot. A conversation send is recorded only where that shared conversation exists. **Fix** Nothing is thrown; log the `warning` if continuity matters. On Slack, the bot's earlier posts are read in when someone first mentions it in that thread.
  </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` works only on WhatsApp and SMS, and `email` only on email and Slack.
  </Accordion>

  <Accordion title="A Slack send throws not_in_channel">
    **Cause** The agent's Slack bot is not a member of that channel. **Fix** Invite it with `/invite @your-bot` in the channel, then send again.
  </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>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.