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

### 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                         // unused for this channel — leave null
enabled: true
```
**Why `destination` is null:** the recipient is **not** a fixed address — it's the contact of the
workflow run. The WHATSAPP_TEMPLATE dispatch branch (`mk1-tasks/tasks/custom_notification_task.go` →
`sendWhatsAppTemplateNotification`) **never reads `destination`**: it takes the run's `contactID`,
does `FetchBasicContact`, and sends to that contact's `whatsappNumber`. So `null` isn't a flag that
"enables contact mode" — the field is simply irrelevant here and stays empty. (Contrast: team-alert
channels like SLACK/EMAIL/SMS **do** read `destination` as the fixed target, e.g. Slack does
`NewSlackService(*destination)`.) Template name/language/components + `variableMappings` all 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 (`{{name}}`, `{{pain}}`) 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 fills each {{param}}
  components, buttons
}
```
So to change what a variable renders to, you edit 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.

---

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