@cool-ai/beach-channel-whatsapp
v0.2.4
Published
WhatsApp edge for Beach applications — webhook inbound and Cloud-API outbound, gated by Delivery Manifests.
Downloads
508
Readme
@cool-ai/beach-channel-whatsapp
WhatsApp channel adapter for Beach. Webhook-driven inbound, Cloud-API outbound. Designed to be gated by a Delivery Manifest from @cool-ai/beach-core's ManifestRegistry — the package itself stays thin and channel-agnostic beyond the inbound/outbound edges.
Home: cool-ai.org · Documentation: cool-ai.org/docs
Install
npm install @cool-ai/beach-channel-whatsapp@cool-ai/beach-transport-whatsapp is a direct dependency, not a peer — the wire layer ships with the channel.
Quick start
import { WhatsAppChannel } from '@cool-ai/beach-channel-whatsapp';
import express from 'express';
const channel = new WhatsAppChannel({
id: 'whatsapp',
transport: {
phoneNumberId: process.env.META_PHONE_NUMBER_ID!,
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
tokenProvider: async () => process.env.META_BEARER_TOKEN!,
},
onInbound: async (missive) => {
// See "Delivery Manifest wiring" below.
},
});
const app = express();
// Do NOT mount express.json() ahead of the webhook — Meta signs raw bytes.
app.all('/whatsapp/webhook', channel.webhookHandler());
app.listen(3000);Delivery Manifest wiring
WhatsApp is a batched outbound — interim respond() parts ("I'm checking") must not be sent, only the final reply. The standard Beach pattern for this is the Delivery Manifest (see beach-core README § Two patterns of use).
import { ManifestRegistry, Manifest } from '@cool-ai/beach-core';
import { SessionTurnManager } from '@cool-ai/beach-session';
import { WhatsAppChannel } from '@cool-ai/beach-channel-whatsapp';
const registry = new ManifestRegistry();
const sessionManager = new SessionTurnManager({ router });
const channel = new WhatsAppChannel({
transport: { /* ... */ },
onInbound: async (missive) => {
// 1. Persist the inbound missive.
await missiveStore.write(missive);
// 2. Open a Delivery Manifest keyed by the inbound missive ID. The
// outbound message will be held until `main_reply` is filled.
const manifestId = `whatsapp-delivery:${missive.id}`;
const manifest = new Manifest({
id: manifestId,
expected: ['main_reply'],
onComplete: async (filled) => {
await channel.sendFormatted({ inbound: missive, filledSlots: filled });
},
});
registry.register(manifest);
// 3. Resolve a session and run a turn. The actor's final respond() fills
// main_reply; interim respond() parts are ignored by the batched edge.
const sessionId = await resolveSession(missive.origin.address!);
const turnId = randomUUID();
const settled = await sessionManager.runTurn({
sessionId, turnId,
actorId: 'concierge',
actorConfig: { /* ... */ },
registry: tools,
provider,
inboundMessage: { role: 'user', content: missive.parts[0]?.text ?? '' },
slotKey: 'concierge.reply',
});
// 4. Fill the Delivery Manifest with the full parts array so the formatter
// can render it.
registry.deliver(manifestId, 'main_reply', { parts: settled.parts });
},
});The manifest lives above the turn. The session manager, router, and actor know nothing about it. The outbound edge (the onComplete handler) is the only component that decides "WhatsApp" — same pattern as @cool-ai/beach-channel-email.
Custom formatters
sendFormatted defaults to WhatsAppTextFormatter — a baseline that walks the main_reply slot, joins text-bearing parts with blank-line separators, and produces an OutboundWhatsApp text payload. Pass a different formatter to render a2ui-surface parts as interactive buttons, artifact parts as media messages, or anything else WhatsApp's wire format supports:
import type { ChannelFormatter } from '@cool-ai/beach-format';
import type { OutboundWhatsApp } from '@cool-ai/beach-channel-whatsapp';
const interactiveFormatter: ChannelFormatter<OutboundWhatsApp> = {
channelClass: 'whatsapp',
async format({ inbound, filledSlots }) {
// Build interactive-buttons or interactive-list payload from the slots.
return {
messageType: 'interactive-buttons',
to: inbound.origin.address!,
body: 'Pick one:',
buttons: [
{ id: 'A', title: 'Yes' },
{ id: 'B', title: 'No' },
],
};
},
};
await channel.sendFormatted({ inbound, filledSlots }, interactiveFormatter);Inbound translation
WhatsAppInboundReceiver translates wire-shaped ParsedInboundWhatsApp into a Beach Missive:
| Wire field | Missive field |
|---|---|
| messageId (wamid) | origin.messageId and externalId |
| from (E.164 phone) | origin.address |
| profileName | origin.displayName (when present) |
| toPhoneNumber | destination.to[0] |
| quotedMessageId | threadId (when present) and inReplyTo |
| (no quote) | threadId = from — conversations cluster by phone |
| content.kind | mapped to parts[] per the table below |
Inbound content kinds are translated to parts as follows:
| Inbound kind | Parts produced |
|---|---|
| text | [{ partType: 'response', text: body }] |
| image / video / sticker | response summary + artifact carrying wire metadata |
| audio (voice or non-voice) | response: '[voice note]' or '[audio]' + artifact |
| document | response: '[document: filename — caption]' + artifact |
| location | response: '[location: name]' + domain-data with raw fields |
| button-reply / list-reply | response: title + domain-data with selection id |
| reaction | domain-data only — reactions are not LLM input |
| contacts / unsupported | domain-data preserving the wire payload |
The pattern: the response part always carries something an LLM-shaped consumer can read; the artifact or domain-data part preserves wire fidelity for advanced consumers.
What this package does and does not do
Does:
- Mounts a Meta-compatible webhook handler (signature verification, GET handshake) via
webhookHandler(). - Translates parsed wire content into a Beach
Missivewith channel-agnostic identity (origin,destination,threadId). - Sends WhatsApp Cloud API messages — text, media, interactive, reaction — via
send(). - Applies CR-129 delivery rules to settled Manifest slots before formatting via
sendFormatted(). - Provides
WhatsAppTextFormatteras the baselineChannelFormatter<OutboundWhatsApp>.
Does not:
- Maintain a MissiveStore (consumer-owned).
- Own sessions, turns, or any actor machinery (consumer-owned via
@cool-ai/beach-session). - Upload media to Meta's
/mediaendpoint — consumers either use a publiclinkor upload themselves and pass the resultingmediaId. - Surface delivery status callbacks (
sent/delivered/read) — Meta delivers these alongsidemessages[]; status-event handling lands as a follow-on if a consumer needs it. - Rate-limit outbound sends — Meta enforces per-phone-number rates; consumers wrap
send()themselves until a shared rate-limit primitive lands. - Support multiple business phone numbers per channel instance — run one
WhatsAppChannelper number. - Render template messages — Meta's pre-approved template flow is a separate API surface; ship a richer formatter when needed.
Testing
- Unit tests in this repo cover the Missive translation for every inbound content kind, the formatter (truncation, empty-slot rejection, parent-quoting), and the channel's delivery-rule + formatter integration. No network.
- Integration tests against the real Meta API are left to the consumer. The Cloud API exposes test phone numbers for this purpose.
Related
beach-transport-whatsappREADME — the wire layer underneath this package.beach-channel-emailREADME — sibling channel package; shares the inbound-translation + Delivery Manifest pattern.beach-formatREADME —ChannelFormatter, delivery rules, the rendering primitives.- Setting up WhatsApp guide — end-to-end walk-through.
