---
name: enviar-campana
description: "Send a WhatsApp campaign to many contacts, now or scheduled: the same message for all, a message with variables, or an image per person (a text list or product cards). Line, approved template, audience, samples, test, then launch after the user confirms. Use to send, launch, schedule or prepare a campaign, broadcast, plantilla or flyer."
license: "Proprietary. Free to use with a Frepi account. See LICENSE."
metadata:
  author: "frepi-ai"
  version: "3"
---

# Enviar una campaña

One recipe for every send. Tool names are the MCP names; where the app assistant (Asistente) uses
another name, both are given at first use.

## Which case

| Case | Load |
| --- | --- |
| An image or list per person (a text list without photos, or product cards with photos and prices) | [flyer](references/flyer.md), section "Which image?", **before any catalog call** (`upsert_products`, `list_products`) |
| The template has no variables | [sin-variables](references/sin-variables.md) |
| The template has variables: the contact's name, a CSV column for this campaign, a fixed text, or a mix | [con-variables](references/con-variables.md) |

Load a reference with `bi_get_skill(name="enviar-campana", reference="con-variables")` (Asistente:
`get_skill`).

## Shared steps

1. **Line.** `list_phone_numbers`. The most specific setting wins, per contact:
   - **A contact's own line** (step 3): that line sends to that contact.
   - **The campaign's line**: `origin='line'` + `phone_number_id` (the Frepi UUID, never the
     provider's id): everyone else from that line.
   - **The engine**: `origin='owner'`, or omit both: each contact's owner line. Contacts without an
     owner are held, never sent from another line. `assign_owners` writes the owner book.

   A set line (contact or campaign) sends even if another line served the contact in the last 24 h.
   No line set = the engine, which respects that lock: those contacts are skipped. If the user asked
   for a specific line and it is not obvious which, ask.
2. **Template.** `list_templates`, `approved` only.
3. **Audience.** As the reference says. An audience made only for this campaign (a CSV for this
   send, a test or a QA list) takes `listed=false`: it stays out of Audiencias and is deleted with
   the draft when no other campaign uses it. Keep the default `listed=true` only when the user wants
   to keep the audience in Audiencias. To send some contacts from a given line, give them their own
   line: `contacts:[{phone, line}]` in a static list, or a `linea_origen` column in the CSV (a line
   UUID or its label). That line is for this audience only; it never moves the lead (`assign_owners`
   does). Contacts on several lines make one draft per line; that send is now-only (no schedule,
   lotes or form).
4. **Draft.** `create_broadcast_draft`. It never sends by itself unless it has `scheduled_at`.
5. **Samples.** Read the returned `samples` (the first messages, no phones) and `missing` (rows
   that will not be sent) with the user. On a filter audience `samples` is null: read
   `preview_audience` instead (Asistente: `preview_audience_recipients`). Then read the lock
   forecast: `get_broadcast` on the draft returns `desk_lock_forecast` — per line, `will_skip`
   (another line holds them and the engine skips them), `will_pass` (a set line sends anyway), the
   holder and `release_at`. Tell the user, e.g. «3 contacts are held by Lucimara until 13/11 13:03;
   they will be skipped. Set a line on them to send anyway.» It is a forecast.
6. **Launch.** `launch_broadcast`, **only after the user's explicit confirmation; never launch
   without it.** In the app assistant it shows a confirmation card. Over MCP there is no card, so
   ask in the chat first.

### Now or scheduled

- Now: steps 1–6.
- Scheduled: `scheduled_at` on `create_broadcast_draft` (ISO-8601 with offset, 1 minute to 1 year
  ahead) is itself the commitment to send. Confirm the recipient count and the time with the user
  before that call; the cron sends it without `launch_broadcast`.
- `origin='owner'` and the flyer are send-now only.

## Common errors

| Code | What to do |
| --- | --- |
| `ORIGIN_LINE_NOT_DECLARED` | `origin='line'` needs `phone_number_id`. Or omit both to let the engine decide. |
| `owner_lock` (`failure_breakdown.reason_code`) | Another line served the contact in the last 24 h, so the engine skipped it. Read `desk_lock.holders[].message`: send from that line, wait until `release_at`, or set the line. |
| `CONTACT_LINE_UNKNOWN` / `CONTACT_LINE_AMBIGUOUS` | A contact's line is not a line here, or the label matches two lines. Use the id from `list_phone_numbers`. |
| `CONTACT_LINE_NOT_ALLOWED` / `CONTACT_LINE_BLOCKED` | The user may not send from that line, or it is blocked. Pick another line. |
| `AUDIENCE_CONTACT_LINES_UNSUPPORTED` | Contacts on other lines cannot go with a form, a schedule or lotes. Send now, or drop their lines. |
| `TEMPLATE_NOT_APPROVED` | Pick an `approved` template. |
| `AUDIENCE_FUENTE_MISMATCH` | The audience is bound to another line: use that line, or recreate the audience. |
| `AUDIENCE_OR_PHONE_NOT_FOUND` | The audience or line id is not in this workspace. List them again. |
| `BROADCAST_FROZEN_VARIABLE` | A greeting collapsed to one value. Recreate the list with real names; never relaunch. |
| `UNRESOLVABLE_VARIABLE_KEY` | A key or a `fill` column is not on the list. Use a column the CSV has. |
| `INVALID_FILL` | `fill` is malformed, or a `static` value is empty. |
| `INVALID_CSV` | Fix the CSV header (a phone column + valid names) and retry. |
| `DUPLICATE_NAME` | An audience with that name exists. Pick another name. |
| `CONTACTS_CAP` | Too many contacts for one audience. Split it. |
| `SCHEDULED_AT_INVALID` | ISO-8601 with offset, 1 minute to 1 year ahead. |
| `BROADCAST_NOT_LAUNCHABLE` | Not a draft (already sent or failed). Fix the cause and create a new draft. |
