# Outbound WhatsApp Playbook

Reaching **Instagram leads that match custom-property conditions** on **WhatsApp**, MCP-first.
WhatsApp's 24-hour customer-service window means any outbound to a cold/idle lead must be a
**Meta-approved template** — you cannot send free-form text. This guide covers the two ways to do
that through the cortex-gateway MCP, with no DB access.

> Pairs with `workflows.md` (workflow engine), `custom-properties.md` (how properties are set),
> and `mcp-tool-reference.md` (the full tool map). Property values are written by the **external
> evaluator**, never by this flow — here we only *read* them to pick the audience.

---

## The two paths

| | **Manual / batch blast** | **Workflow-driven (automated)** |
|---|---|---|
| When | A one-off send to a list you pick *now* | The "usual" way — fire automatically when a lead *becomes* eligible |
| Trigger | You run the tools | A property condition flips on a contact |
| Driver tool | `send_whatsapp_template` | `upsert_workflow` (`type: CUSTOM_NOTIFICATION`) |
| Template id used | `templateId` (Meta numeric) + `templateName` + `languageCode` | `whatsappTemplateDefinitionId` (internal UUID) |
| Variables filled via | `variableMappings` arg on the send | `definition.variableMappings` inside the template **definition** |
| Reaches | the **lead** (the `contactIds` you previewed) | the **lead** (resolved per workflow run) |

Both deliver to the lead. Use the **manual** path for a deliberate, one-time blast to an audience you
curate; use the **workflow** path to put outreach on autopilot (e.g. "when `call_booked` flips true,
send the pre-call confirmation").

---

## Eligibility (server-side, applies to both paths)

A contact only receives a template if **both** hold (enforced in mk1-chat, re-checked at send/preview —
not something the prompt or filters can override):

- `whatsappNumber` is non-empty, **and**
- `isWhatsappSubscribed: true`.

So filtering for `has_whatsapp_number: true` + `is_whatsapp_subscribed: true` in `search_contacts` is
audience *selection*; the **preview** is the real eligibility gate (it drops the rest).

---

## Path A — Manual / batch blast

A deliberate, HIGH-IMPACT send (real WhatsApp, billed by Meta). Never skip preview + explicit
confirmation.

**1. Pick the template** — `list_whatsapp_templates` → only `APPROVED` come back. Note `templateId`,
`name`, `language`, and the named params (`components[].example.body_text_named_params[].param_name`).

**2. Build the audience** — `search_contacts`. This is the **only** tool that returns `contact_id`
(which preview/send require). `search_conversations` does **not** — it returns `conversation_id` +
`contact_username` and is useless here.
- Native filters: `name`, `email`, `phone`, `whatsapp_number`, `instagram_username`,
  `is_whatsapp_subscribed`, `has_whatsapp_number`, `created_after/before`.
- Ad attribution: `from_ads: true` → only contacts whose conversation started from a Meta ad
  (Instagram/Facebook click-to-Direct); `from_ads: false` → only non-ad contacts; `ad_id` →
  only contacts attributed to that exact Meta ad id (implies `from_ads`). When either is set,
  each contact is enriched with first-touch ad metadata (`referral_ad_id`, `ad_title`, etc.).
- `custom_properties[]`: each `{ codename, ... }` resolves by the property's type:
  - boolean → `equals_boolean`
  - integer → `min` / `max`
  - option → `value` (option text, case-insensitive)
  - string → `value` (case-insensitive *contains*)
- Get codenames from `list_custom_properties`. Paginate with `page` / `limit` (max 100).
- Collect the `contact_id` values.

**3. Preview (mandatory)** — `preview_whatsapp_template_recipients` with those `contactIds`. It
re-applies eligibility server-side and returns `eligibleCount` / `excludedCount` / `eligibleSample`.
**Show the counts + sample to the user and get explicit confirmation.**

**4. Send** — `send_whatsapp_template` with `templateId`, `templateName`, `languageCode`, the **same**
`contactIds` you previewed, and `userConfirmed: true`. Optional: `variableMappings`, `components`,
`buttons`. Returns `createdCount` / `skippedCount`.

**Gotchas**
- Pass **`contact_id`**, never a `contact_username` (→ 500) or a `conversation_id` (→ silently
  excluded). Only `search_contacts` ids resolve.
- `variableMappings` format for named params is forwarded opaquely by mk1-chat to mk1-tasks (Go),
  where the actual mapping to Meta named params lives. The shape used in practice is
  `[{ "param_name": "name", "text": "..." }, ...]`; the send is accepted (`ok:true`) regardless, so
  **verify the rendered message** on the recipient side rather than trusting acceptance alone.

---

## Path B — Workflow-driven (the automated "usual" way)

Outreach fires automatically when a lead's custom property matches. It's a **chain of three
entities**, built bottom-up:

```
WhatsappTemplateDefinition   (the template + its variable bindings)   ← whatsappTemplateDefinitionId
        ▲ referenced by
NotificationDestination      channel: WHATSAPP_TEMPLATE, destination: null
        ▲ linked via destinationIds
CustomNotification           level: MESSAGE
        ▲ linked via customNotificationId
Workflow                     type: CUSTOM_NOTIFICATION + propertyConditions  ← the gate
```

### 0. Template definition — `upsert_whatsapp_template_definition`
```
templateId: "<Meta templateId from list_whatsapp_templates>"
variableMappings: { "<varId>": "<value>", ... }   // id→value; ids come from a prior list/upsert
// templateName / languageCode default to the live Meta template when omitted
```
Returns `{ created, definition: { id, templateId, templateName, languageCode, variableMappings } }`.
**The `definition.id` is the `whatsappTemplateDefinitionId`** you wire into step 1. mk1-chat rebuilds
the full template structure (`components`/`buttons`) from the live Meta template on every upsert — you
only supply the variable **values**. To discover the variable ids for a brand-new template, call once
with no/partial `variableMappings` and read them from the returned `definition.variableMappings`, then
call again with the `id→value` map. Identity is the natural key `(templateId, influencerId)`: omit `id`
to create-or-update by it. `list_whatsapp_template_definitions` lists existing definitions + their ids.

> **Each `value` is a TOKEN, not free text — and every variable MUST resolve non-empty.** This path does
> **not** accept a literal string/URL: each `value` names a field that is filled from the lead at send
> time, and any value that isn't a recognized token (including literal text) resolves to an **empty
> string**. An empty parameter makes Meta reject the **whole** message with **error 131008 "Required
> parameter is missing."** So a variable left unmapped (`value: ""`) — or set to arbitrary text — breaks
> the send. Recognized tokens:
> - **Contact fields:** `firstName`, `lastName`, `email`, `phoneNumber`, `whatsappNumber`,
>   `whatsappUserId`, `instagramUsername`
> - **Custom property:** `custom_<customPropertyID>` (the prefix `custom_` + the property's UUID from
>   `list_custom_properties`)
> - **Schedule fields:** `scheduleName`, `scheduleDatetime`, `scheduleDate`, `scheduleTime`,
>   `scheduleDuration`, `scheduleUrl` — only filled when the run has a Schedule attached, i.e. a
>   `SCHEDULE_REMINDER` workflow. A `CUSTOM_NOTIFICATION` workflow has no Schedule, so these resolve
>   empty there — don't use `scheduleUrl` in a CUSTOM_NOTIFICATION.
>
> **There is no token for a studio/conversation link.** If a template has a `{{link}}` that's meant to
> point at the conversation, a notification workflow cannot fill it — map that variable to a contact
> field (e.g. `phoneNumber`) or a property instead, or use a template without a link variable. The UI's
> "Variable Mapping → Select a property" dropdown surfaces custom properties; via MCP you can set any of
> the tokens above.

### 1. Notification destination — `upsert_notification_destination`
```
channel: "WHATSAPP_TEMPLATE"
name: "WA Template — <template>"
whatsappTemplateDefinitionId: "<definition.id from step 0>"   // the internal definition, NOT the Meta templateId
destination: null                         // null/empty = deliver to the lead (the contact of the run)
                                          // a phone number = deliver to that FIXED number (operator alert)
enabled: true
```
**`destination` controls who receives the template:**
- **`null`/empty → the lead.** The WHATSAPP_TEMPLATE dispatch branch
  (`mk1-tasks/tasks/custom_notification_task.go` → `sendWhatsAppTemplateNotification`) takes the run's
  `contactID`, does `FetchBasicContact`, and sends to that contact's `whatsappNumber`. This is the
  default content-delivery mode.
- **A phone number → that fixed number (operator alert).** Set `destination` to a phone number and the
  template is delivered to that number instead of the lead (e.g. notify the operator when a lead books a
  call). The template **variables still resolve from the lead** (via the token map in the definition —
  see step 0), so the operator sees which lead triggered it. The number is stored **digits-only** (Meta
  `wa_id` format, no `+` or spaces) — the MCP normalizes whatever you pass.

**Multiple operator numbers:** there is no list field. Create **one WHATSAPP_TEMPLATE destination per
number** (each with its own `destination` phone), then link them all to the same custom notification in
step 2 (`destinationIds: [...]`). The dispatch loop sends to every linked destination in one run.

(Contrast: team-alert channels like SLACK/EMAIL/SNS also read `destination` as a fixed target — there
it is the webhook URL and is required.) Template name/language/components + `variableMappings` always
come from the definition referenced by `whatsappTemplateDefinitionId`.

### 2. Custom notification — `upsert_custom_notification`
```
name: "<label>"
level: "MESSAGE"            // MESSAGE = deliver the template to the contact
                            // (INFO/WARNING/ERROR are team alerts)
enabled: true
destinationIds: ["<destination id from step 1>"]
```
`title` / `body` stay null for a template send — the message body is the template itself.

### 3. Workflow — `upsert_workflow`
```
type: "CUSTOM_NOTIFICATION"
name: "<label> — <when>"
enabled: true
customNotificationId: "<notification id from step 2>"
onlyRunOnce: true                       // one-shot per conversation; false to allow re-fire
propertyConditions: [
  { customPropertyId: "<id from list_custom_properties>",
    operator: "EQUALS", valueBoolean: true }
]
agentIds: [...]      // optional — scope to specific agents; omit/[] = all
channels: [...]      // optional — e.g. ["INSTAGRAM"]; omit/[] = all
```
Property-condition operators: `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `NOT_CONTAINS`, `GREATER_THAN`,
`LESS_THAN`, `GREATER_THAN_OR_EQUAL_TO`, `LESS_THAN_OR_EQUAL_TO`, `IS_EMPTY`, `IS_NOT_EMPTY`,
`IS_NOT_DEFINED`. Put the value in the field matching the property type: `valueBoolean` / `valueInt`
/ `valueString` / `valueDateTime` / `valueOptionPropertyId`. **All conditions must pass (AND).**

### Where the template variables come from
Named params (`{{lead}}`, `{{link}}`) are **not** set on the workflow or the notification. They live in
the template **definition** JSON:
```
definition: {
  templateId, templateName, languageCode,
  variableMappings: [ { id, name, value, locations: string[] } ],   // value = a TOKEN (see step 0)
  components, buttons
}
```
Each `value` is a **token** filled from the lead at send time — **not** the literal text that renders.
Use a contact field (`firstName`, `phoneNumber`, …), a `custom_<propertyID>`, or a schedule token;
unrecognized values (incl. literal text/URLs) resolve to empty and trigger Meta 131008. See the boxed
note in **step 0** for the full token list and the empty-param gotcha. So to change what a variable
renders to, you edit the token in the `WhatsappTemplateDefinition`, not the workflow.

### ✅ MCP gap closed (path B is now 100% MCP)
The `WhatsappTemplateDefinition` entity is now exposed via MCP by `cortex-gateway-service`:
`list_whatsapp_template_definitions`, `upsert_whatsapp_template_definition`, and
`delete_whatsapp_template_definition` (see step 0). So you can build the whole chain — definition →
destination → notification → workflow — without touching the Ninjo UI: upsert the definition to mint
its `id`, then feed that into `upsert_notification_destination`.

The tools proxy mk1-chat's internal `/api/internal/cortex/whatsapp-template-definition-{list,upsert,delete}`
endpoints; mk1-chat owns the definition-building (it rebuilds `components`/`buttons` from the live Meta
template). Notes: the `templateId` must be an **APPROVED** template from `list_whatsapp_templates` (else
404); `delete` is **refused with 409** while a notification destination still references the definition
(detach/delete the destination first — the FK is `onDelete: SetNull`, so it's never silently orphaned).

---

## Custom-property gating — quick reference

The same property powers both paths (filter in `search_contacts`, gate in `propertyConditions`).
`list_custom_properties` gives, per property: `id` (for workflow conditions), `codename` (for
`search_contacts`), `type`, and `options`. Match the value to the type:

| Type | `search_contacts` filter | Workflow condition field |
|---|---|---|
| boolean | `equals_boolean` | `valueBoolean` |
| integer | `min` / `max` | `valueInt` (with GREATER/LESS operators) |
| string | `value` (contains) | `valueString` (with CONTAINS) |
| option | `value` (option text) | `valueOptionPropertyId` |

---

## Verify & debug

- **Did the workflow fire?** `get_workflow_activity` (fires/evaluations over N days) — check before
  suspecting anything else. `list_workflows` shows `lastRun` per workflow.
- **Workflow silently not firing?** Two classic blockers (see `platform-internals.md` §3a/§3b):
  NULL property conditions and a conversation in `needs_attention`.
- **Manual send "worked" but message looks wrong?** Acceptance (`ok:true`) ≠ rendered correctly —
  check the recipient's WhatsApp for unfilled `{{param}}` and fix the variable mapping.
- **Workflow/notification send fails with Meta error 131008 "Required parameter is missing"?** A template
  variable resolved to **empty**: it was left unmapped, set to literal text/URL (not a token), set to a
  `custom_<id>` the lead doesn't have, or a schedule token in a `CUSTOM_NOTIFICATION` (no Schedule there).
  Map every variable to a token that the lead actually has a value for (step 0). Also a real lead must
  have that field populated (e.g. `firstName`/`phoneNumber` non-empty) or it 131008s on that contact.

---

## Confirmation discipline (both paths are HIGH IMPACT)

Real, billed WhatsApp messages. Due diligence — **preview + show counts/sample + explicit user
confirmation** — happens **before** the send/enabling, not after. Once the user has explicitly
confirmed the audience and content, execute; don't re-litigate a non-blocking format detail
afterward (verify the result instead).
