# WhatsApp Integration

> WhatsApp messaging integration via Kapso

Struere integrates with WhatsApp Business through Kapso's Cloud API, providing 7 built-in tools for sending messages, templates, interactive menus, and media. WhatsApp conversations are tracked as threads with channel metadata (phone number, contact name) accessible in system prompts via template variables and in custom tools via the execution context.

## Architecture

```
WhatsApp User
    |
    v
WhatsApp Business API
    |
    v
Kapso Service (manages phone numbers, message routing)
    |
    v
Convex Webhooks (/webhook/kapso/project, /webhook/kapso/messages)
    |
    v
Struere Backend (message storage, agent routing)
    |
    v
Agent LLM Execution
    |
    v
Kapso API (outbound message delivery)
    |
    v
WhatsApp User receives response
```

## Database Tables

### whatsappConnections

Stores the connection state between an organization and a WhatsApp phone number. Scoped by environment.

| Field | Type | Description |
|-------|------|-------------|
| `organizationId` | `Id<"organizations">` | The owning organization |
| `environment` | `"development" \| "production"` | Environment scope |
| `status` | `"disconnected" \| "pending_setup" \| "connected"` | Current connection state |
| `phoneNumber` | `string?` | The connected phone number |
| `kapsoCustomerId` | `string?` | Kapso customer identifier |
| `kapsoPhoneNumberId` | `string?` | Kapso phone number identifier |
| `agentId` | `Id<"agents">?` | The agent assigned to handle inbound messages |
| `routerId` | `Id<"routers">?` | The router assigned to handle inbound messages (alternative to `agentId`) |
| `setupLinkUrl` | `string?` | URL for the phone number setup flow |
| `lastConnectedAt` | `number?` | Timestamp of last successful connection |
| `lastDisconnectedAt` | `number?` | Timestamp of last disconnection |

### messages (WhatsApp)

WhatsApp messages are stored in the unified `messages` table alongside all other conversation messages. WhatsApp-specific fields (direction, phone number, delivery status, Kapso message ID) are stored in the `channelData` field on each message record.

## Setup Flow

### 1. Enable the Integration

Enable WhatsApp for your organization and environment through the dashboard or API:

```typescript
await whatsapp.enableWhatsApp({ environment: "development" })
```

This creates an integration config entry with provider `"whatsapp"` and status `"active"`.

### 2. Start Phone Setup

Initiate the WhatsApp phone number connection:

```typescript
await whatsapp.setupWhatsApp({ environment: "development" })
```

This triggers an asynchronous flow:
1. A Kapso customer is created for your organization
2. A setup link URL is generated
3. The connection status moves to `"pending_setup"`
4. The setup link is stored on the connection record

### 3. Complete Phone Connection

The user follows the setup link to connect their WhatsApp Business phone number through Kapso's interface. Once complete, the `whatsapp.phone_number.created` webhook fires and:

1. The connection status updates to `"connected"`
2. The Kapso phone number ID and phone number are stored
3. A message webhook is registered with Kapso pointing to `/webhook/kapso/messages`

### 4. Assign an Agent or Router

Assign an agent to handle inbound messages:

```typescript
await whatsapp.setWhatsAppAgent({
  agentId: "agent_id_here",
  environment: "development",
})
```

Alternatively, assign a router to route messages between multiple agents. When `routerId` is set, inbound messages go through the router's classification or rules engine instead of directly to a single agent:

```typescript
await whatsapp.setWhatsAppRouter({
  routerId: "router_id_here",
  environment: "development",
})
```

You can also set the router via the CLI:

```bash
struere whatsapp set-router --slug support-router
```

A WhatsApp connection can have either an `agentId` or a `routerId`, but not both.

## Footguns

Behaviors that aren't obvious from the type signatures.

### Symptom: Inbound WhatsApp messages are silently dropped
**Cause:** WhatsApp connection has no router AND no agent assigned (or its router has no fallback). `processInboundMessage` returns null without warning.

**Fix:** Ensure every connection has either a `routerId` (with a valid `fallback`) or an `agentId`. Verify with `bunx struere integration whatsapp --status`.

### Symptom: `whatsapp.send` (interactive/template) throws "24-hour window expired" even though the customer just replied
**Cause:** Window check reads from `thread.metadata.lastInboundAt`, but inbound timestamps are written to `thread.channelParams.lastInboundAt`. The metadata field is never populated.

**Fix (workaround):** Send a regular `whatsapp.send` text first to refresh the window state, then send your interactive message. Real fix is a platform patch.

### Symptom: Template returns `{ status: "sent" }` but customer never receives it
**Cause:** `sendTemplate` doesn't pre-validate template approval status. If the template isn't APPROVED in Meta's system, the send silently no-ops.

**Fix:** Check template approval state via `bunx struere templates list` before relying on it. Templates pending approval are not visible.

### Symptom: Templates created in dev show up in production (or vice versa)
**Cause:** `whatsappOwnedTemplates` is **org-scoped, not env-scoped** -- by design (Meta enforces template uniqueness per WABA).

**Fix:** Name templates accordingly (`prod_welcome` vs `dev_welcome`) if you want env separation.

### Symptom: Voice messages arrive without text
**Cause:** Kapso may deliver the audio webhook before the transcript is ready; the platform doesn't refetch transcripts later.

**Fix:** Handle the `[Sent a voice message]` placeholder explicitly in your agent's prompt.

### Symptom: Same inbound message appears twice in your thread after a Kapso retry
**Cause:** Dedup is via `externalId` only; if the insert succeeds but the response times out, Kapso retries and the second insert is blocked by the unique index -- but if either step is fast and the index isn't checked tightly, edge cases exist.

**Fix:** In your agent prompt, treat duplicates defensively (check `externalId` if you persist domain state from the message).

See [Platform Gotchas](/platform/gotchas) for cross-cutting silent failures across the platform.

## Inbound Message Routing

When a WhatsApp message arrives, the following sequence executes:

```
POST /webhook/kapso/messages
    |
    v
Verify HMAC-SHA256 signature
    |
    v
Look up connection by kapsoPhoneNumberId
    |
    v
processInboundMessage (store message, deduplicate by messageId)
    |
    v
scheduleAgentRouting (mutation, schedules action via ctx.scheduler)
    |
    v
routeInboundToAgent (action)
    |
    v
threads.getOrCreate with externalId = "whatsapp:{connectionId}:{phoneNumber}"
    |
    v
agent.chatAuthenticated (system actor, no user)
    |
    v
Send response via Kapso sendTextMessage API
```

### Thread Reuse

Conversations with a given phone number are grouped into a single thread using the `externalId` pattern `whatsapp:{connectionId}:{phoneNumber}`. The `threads.getOrCreate` mutation looks up existing threads by this external ID, ensuring all messages from the same WhatsApp number flow through the same conversation context.

### Thread Data

When a WhatsApp conversation creates or resumes a thread, the following data is stored on the thread:

| Field | Value |
|-------|-------|
| `channel` | `"whatsapp"` |
| `externalId` | `whatsapp:{connectionId}:{phoneNumber}` |
| `channelParams.phoneNumber` | Sender's phone number with country code (e.g., `+56912345678`) |
| `channelParams.contactName` | WhatsApp profile display name (if available) |
| `channelParams.lastInboundAt` | Timestamp of the last inbound message |

This data is available to agents in two ways:

**In system prompts** via template variables:

```
Sender phone: {{threadContext.params.phoneNumber}}
Sender name: {{threadContext.params.contactName}}
Channel: {{threadContext.channel}}
```

**In custom tools** via the `context` parameter:

```typescript
handler: async (args, context, struere) => {
  const phone = context.channelParams?.phoneNumber
  const results = await struere.entity.query({
    type: "teacher",
    filter: { field: "phone", operator: "eq", value: phone },
  })
  return results?.results?.[0] || { error: "Teacher not found" }
}
```

This enables patterns like auto-identifying the sender by phone number and loading their entity data without asking the user to identify themselves.

### System Actor Context

Inbound WhatsApp messages are processed using a **system actor context** because there is no authenticated Clerk user for the incoming message. The system actor has `isOrgAdmin: true` and operates with full permissions within the organization's environment.

## Outbound Messages

Agents send responses back to WhatsApp users through the Kapso API. When the agent's LLM loop completes:

1. The response text is extracted from the agent's reply
2. The `sendTextMessage` function calls the Kapso API with the phone number and text
3. The outbound message is stored in the `messages` table with WhatsApp-specific data in `channelData`
4. Delivery status updates arrive via the status update webhook

## Message Status Tracking

Outbound message status progresses through these states:

```
sent -> delivered -> read
  \
   -> failed
```

Status updates are received via the `whatsapp.message.status_update` event type on the messages webhook and applied to the corresponding message record.

## WhatsApp Tools

Agents can also interact with WhatsApp programmatically through built-in WhatsApp tools:

### whatsapp.send

Send a text message to a phone number:

```typescript
{
  to: "+1234567890",
  text: "Your session is confirmed for tomorrow at 3 PM."
}
```

### whatsapp.getConversation

Retrieve message history for a phone number:

```typescript
{
  phoneNumber: "+1234567890",
  limit: 20
}
```

### whatsapp.getStatus

Check the current WhatsApp connection status for the organization.

## Template Management

WhatsApp message templates are pre-approved message formats required for outbound messages outside the 24-hour messaging window. Struere supports full template lifecycle management — create, list, check status, delete — directly from the dashboard and API.

Templates are stored on Meta's side and queried dynamically via the Kapso Meta proxy. There is no local caching table.

### Template Categories

| Category | Use Case |
|----------|----------|
| `UTILITY` | Transactional updates (order confirmations, appointment reminders) |
| `MARKETING` | Promotional content and offers |
| `AUTHENTICATION` | OTP/verification codes (special Meta rules apply) |

### Template Status Flow

```
Created -> PENDING -> APPROVED
                  \-> REJECTED
                  \-> PAUSED
```

Templates must be approved by Meta before they can be sent. Status is checked by querying the Meta API directly.

### Creating Templates

Create templates via the dashboard (Settings > WhatsApp > Templates) or the `createTemplate` action:

```typescript
await whatsappActions.createTemplate({
  environment: "development",
  connectionId: "connection_id",
  name: "order_update",
  language: "en_US",
  category: "UTILITY",
  components: [
    {
      type: "BODY",
      text: "Hi {{customer_name}}, your order {{order_id}} is ready.",
      example: {
        body_text_named_params: [
          { param_name: "customer_name", example: "Alex" },
          { param_name: "order_id", example: "ORDER-123" },
        ],
      },
    },
  ],
})
```

**Returns:** `{ id: string, status: string, category: string }`

### Template Component Rules

- **HEADER** (optional): TEXT, IMAGE, VIDEO, or DOCUMENT format
- **BODY** (required): Main message text with optional variables
- **FOOTER** (optional): Short footer text, no variables
- **BUTTONS** (optional): QUICK_REPLY, URL, or PHONE_NUMBER

Parameter formats:
- **NAMED** (recommended): `{{customer_name}}` — use `parameter_format: "NAMED"` at creation
- **POSITIONAL**: `{{1}}`, `{{2}}` — sequential, no gaps

If variables appear in HEADER or BODY, you must include examples in the component.

Button ordering: do not interleave QUICK_REPLY with URL/PHONE_NUMBER buttons.

### Listing Templates

```typescript
await whatsappActions.listTemplates({
  environment: "development",
  connectionId: "connection_id",
})
```

Returns all templates with name, status, category, language, and components.

### Checking Template Status

```typescript
await whatsappActions.getTemplateStatus({
  environment: "development",
  connectionId: "connection_id",
  name: "order_update",
})
```

Returns the template details filtered by name, including current approval status.

### Deleting Templates

```typescript
await whatsappActions.deleteTemplate({
  environment: "development",
  connectionId: "connection_id",
  name: "order_update",
})
```

Deletes the template from Meta. This cannot be undone.

### Sending Template Messages

For sending approved templates in a conversation, use the `sendTemplate` action:

```typescript
await whatsappActions.sendTemplate({
  threadId: "thread_id",
  templateName: "order_update",
  language: "en_US",
  components: [
    {
      type: "body",
      parameters: [
        { type: "text", parameter_name: "customer_name", text: "Alex" },
        { type: "text", parameter_name: "order_id", text: "ORDER-123" },
      ],
    },
  ],
})
```

Template messages are stored with the text `[Template: templateName]` in the message history.

### Dashboard Template Management

Connected phone numbers display a **Message Templates** section in the WhatsApp settings page. From there you can:

- View all templates with their name, language, category, and approval status
- Create new templates with a JSON component editor
- Delete templates (with confirmation)
- Refresh the template list from Meta

## Disconnecting

To disconnect WhatsApp from an environment:

```typescript
await whatsapp.disconnectWhatsApp({ environment: "development" })
```

This sets the connection status to `"disconnected"` and clears the phone number and setup link fields. The Kapso customer record is retained for potential reconnection.

## Required Environment Variables

| Variable | Location | Description |
|----------|----------|-------------|
| `KAPSO_API_KEY` | Convex | API key for the Kapso service |
| `KAPSO_WEBHOOK_SECRET` | Convex | Shared secret for webhook signature verification |
| `CONVEX_SITE_URL` | Convex | Your Convex site URL (used to construct webhook callback URLs) |
