npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 Missive with 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 WhatsAppTextFormatter as the baseline ChannelFormatter<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 /media endpoint — consumers either use a public link or upload themselves and pass the resulting mediaId.
  • Surface delivery status callbacks (sent / delivered / read) — Meta delivers these alongside messages[]; 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 WhatsAppChannel per 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