# WhatsApp Cloud Plugin — Architecture
This document describes the current, as-built architecture of
`openclaw/plugins/whatsapp-cloud/`, the OpenClaw channel plugin that connects
your OpenClaw agents to Meta's WhatsApp Business Cloud API. It reflects the
code as it exists today, after several rounds of production incidents and
fixes — not the original design. File and function references throughout
point at the real source under this plugin's `src/` directory unless noted
otherwise.
## 1. Overview
`whatsapp-cloud` is a genuine, registered OpenClaw **channel plugin** — not a
standalone service that talks to OpenClaw over some side channel. It is
installed into the `openclaw` gateway container via `openclaw plugins install
--link` (see §6) and participates in the framework's own channel-plugin
contract: `channel.ts` builds a `ChannelPlugin` via
`createChatChannelPlugin`, `index.ts` wires it into the gateway's full
runtime (`defineChannelPluginEntry`), and `setup-entry.ts` wires it into the
CLI's setup/onboarding flow (`defineSetupPluginEntry`).
It supports three message modalities on a single WhatsApp number:
- **Text** — plain back-and-forth conversation.
- **Image generation** — the agent can generate and send an image, via a
dedicated tool backed by LiteLLM's `/images/generations` endpoint.
- **Voice notes** — inbound voice notes are transcribed and handled like
text; the agent explicitly chooses text or voice for its reply per turn
(defaulting to mirroring the input type), via two dedicated tools — see
§2.4.
**Design philosophy: replaces an external-bridge architecture.** This plugin
replaces a previous, now fully-deleted `whatsapp-bridge/` — a standalone
Python FastAPI service that spoke to WhatsApp directly and called into
OpenClaw's agent session over its own client (`whatsapp-bridge/claw_client.py`,
`whatsapp-bridge/app.py`, `whatsapp-bridge/speech.py`, etc., all deleted).
That architecture kept WhatsApp's webhook handling, session management, and
delivery entirely outside OpenClaw's own turn kernel, which meant WhatsApp
conversations didn't get OpenClaw's native session semantics, access-control
gates, or delivery machinery for free — the bridge had to reimplement them.
The current plugin instead runs *inside* the OpenClaw gateway process and
drives the framework's own turn kernel directly
(`inbound.ts`'s `dispatchWhatsappInboundEvent` calls
`channelRuntime.inbound.run(...)`), so WhatsApp gets the same session
persistence, access control, and reply-delivery infrastructure every other
first-class channel (SMS, the bundled `whatsapp` extension, etc.) gets, with
this plugin only supplying the WhatsApp-specific adapters (webhook handling,
Meta Graph API calls, voice codec glue). Several of the STT/TTS/audio
behaviors below are deliberately ported from the old bridge's proven
behavior (e.g. voice-in-voice-out symmetry, Deepgram STT wiring) even though
the transport around them changed completely.
### 1.1 End-to-end example: one message, start to finish
```mermaid
flowchart TD
classDef external fill:#e3f2fd,stroke:#1565c0,stroke-width:1.5px,color:#0d47a1
classDef decision fill:#fff8e1,stroke:#f9a825,stroke-width:1.5px,color:#7f6003
classDef success fill:#e8f5e9,stroke:#2e7d32,stroke-width:1.5px,color:#1b5e20
classDef failure fill:#ffebee,stroke:#c62828,stroke-width:1.5px,color:#7f0000
classDef core fill:#ede7f6,stroke:#5e35b1,stroke-width:1.5px,color:#311b92
classDef agent fill:#e0f2f1,stroke:#00695c,stroke-width:1.5px,color:#004d40
A["📱 Sender phone x
(any number in allowFrom)"]:::external -->|WhatsApp message| B["☎️ Meta WhatsApp Business number y
(registered in Meta Cloud API)"]:::external
B -->|"webhook POST, from: x"| C["🔐 webhook.ts
verify signature, parse"]:::core
C --> D{"msg.type?"}:::decision
D -->|text| E["💬 type=text"]:::core
D -->|audio| F["🎙️ type=audio"]:::core
D -->|image| G["🖼️ type=image"]:::core
D -->|other/unsupported| X["🗑️ dropped silently"]:::failure
E --> H["dispatchWhatsappInboundEvent"]:::core
F --> H
G --> H
H --> I["🔒 resolveWhatsappAccess
checks channels['whatsapp-cloud'].allowFrom"]:::core
I --> J{"is x in allowFrom?"}:::decision
J -->|"no (per configured dmPolicy)"| K["🚫 rejected / paired-off, no turn started"]:::failure
J -->|yes| L["adapter.ingest(raw)"]:::core
L --> M{"event type"}:::decision
M -->|audio| N["⬇️ downloadVoiceNoteMedia
via downloadInboundMediaOrNotifyFailure"]:::core
M -->|image| O["⬇️ downloadImageMedia
via downloadInboundMediaOrNotifyFailure"]:::core
M -->|text| P["rawText passed through directly"]:::success
N -->|success| N2["📝 transcribeVoiceNoteMedia
Deepgram nova-3, whisper-large fallback"]:::success
N -->|"download fails, e.g. >20MB cap"| N3["⚠️ sendText: couldn't process that voice note...
turn dropped, ingest returns null"]:::failure
O -->|success| O2["🖼️ media fact attached, kind=image
caption used as rawText, if present"]:::success
O -->|download fails| O3["⚠️ sendText: couldn't process that image...
turn dropped, ingest returns null"]:::failure
N2 --> Q["👀 markAsRead + typing indicator started"]:::agent
O2 --> Q
P --> Q
Q --> R["adapter.resolveTurn
buildContext with media facts"]:::agent
R --> S["🤖 Agent turn runs on the configured model"]:::agent
S --> T{"agent tool call"}:::decision
T -->|text reply| U["send_text_reply_for_whatsapp"]:::agent
T -->|voice reply| V["send_voice_reply_for_whatsapp"]:::agent
T -->|image gen| W["generate_image_for_whatsapp"]:::agent
U --> Y["✅ Meta Cloud API
message sent from y to x"]:::success
V --> Y
W --> Y
```
### 1.2 System overview
```mermaid
flowchart LR
classDef meta fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
classDef plugin fill:#ede7f6,stroke:#5e35b1,color:#311b92
classDef kernel fill:#e0f2f1,stroke:#00695c,color:#004d40
classDef ext fill:#fff3e0,stroke:#ef6c00,color:#e65100
subgraph Meta["☎️ Meta WhatsApp Cloud API"]
WA[WhatsApp user]:::meta
end
subgraph Plugin["🔌 whatsapp-cloud plugin (src/)"]
WH[webhook.ts]:::plugin
IN[inbound.ts]:::plugin
CH[channel.ts]:::plugin
MA[message-adapter.ts]:::plugin
MC[meta-client.ts]:::plugin
end
subgraph Kernel["🧠 OpenClaw gateway / turn kernel"]
TK[channelRuntime.inbound.run]:::kernel
AG[whatsapp agent session]:::kernel
end
subgraph External["🌐 External services"]
DG[Deepgram STT]:::ext
CT[Cartesia TTS]:::ext
LL[LiteLLM /images/generations]:::ext
PO[Pollinations.ai]:::ext
AH[AI Horde]:::ext
end
style Meta fill:#f5fafe,stroke:#1565c0,stroke-width:1px
style Plugin fill:#f8f6fc,stroke:#5e35b1,stroke-width:1px
style Kernel fill:#f2fbfa,stroke:#00695c,stroke-width:1px
style External fill:#fffaf3,stroke:#ef6c00,stroke-width:1px
WA -- "POST webhook" --> WH
WH --> IN
IN -- "ingest / resolveTurn / delivery" --> TK
TK --> AG
AG -- "media fact: audio" --> DG
AG -- "generate_image_for_whatsapp tool" --> CH
CH -- "/images/generations" --> LL
LL --> PO
LL -.fallback.-> AH
AG -- "send_text_reply_for_whatsapp /\nsend_voice_reply_for_whatsapp tool" --> CH
CH -- "TTS" --> CT
TK -- "durable() truthy (media only)" --> MA
TK -- "durable() false, no explicit\nsend this turn: fallback" --> IN
IN -- "flagged fallback text" --> MC
MA --> MC
CH --> MC
MC -- "text / image / audio" --> WA
```
## 2. Inbound flow
### 2.1 Webhook receipt and signature verification
`webhook.ts`'s `createMetaWebhookHandler` is a raw Node HTTP handler
registered by `channel.ts`'s `registerFull` at `/whatsapp-cloud/webhook` via
`api.registerHttpRoute(...)` (see §6 for why that specific API call matters).
- `GET` requests handle Meta's webhook-verification handshake
(`hub.mode=subscribe`, `hub.verify_token`, `hub.challenge`), comparing the
token with `constantTimeStringEqual` (a `timingSafeEqual` wrapper) against
`WHATSAPP_VERIFY_TOKEN`.
- `POST` requests are the actual event delivery. The body is read with a
hard 1MB cap (`MAX_BODY_BYTES`, `PayloadTooLargeError` → HTTP 413) before
any parsing, then its `X-Hub-Signature-256` header is verified via
`signature.ts`'s `verifyMetaSignature` — an HMAC-SHA256 over the raw body
using `WHATSAPP_APP_SECRET`, compared with `timingSafeEqual`. Only after
signature verification does the handler `JSON.parse` the body.
- `parseMetaWebhookPayload` extracts one `MetaWebhookEvent` per inbound
message (`type: "text" | "audio" | "image"`), dropping anything else
(reactions, status callbacks, unknown message types) silently.
- Forwarding/reply provenance is parsed here too, from Meta's per-message
`context` object, uniformly across all three message types:
`context.forwarded`/`context.frequently_forwarded` (booleans — Meta does
NOT expose *who* originally forwarded it, by design) and `context.id`/
`context.from` (a reply's quoted-message id/sender). Meta's webhook never
includes the forwarded/quoted message's actual *content* — there is no
Cloud API endpoint to fetch an arbitrary historical message by id — so
this is provenance metadata only, not content recovery. See §2.2 for how
`inbound.ts` maps it onto the turn kernel's supplemental context.
- Each parsed event is handed synchronously to `onEvent`, which in
`registerFull` is a `void`-fired call into `dispatchWhatsappInboundEvent`
(§2.2) — the webhook responds `200 {"status":"ok"}` immediately without
waiting for the agent turn to finish.
### 2.2 Turn-kernel dispatch — `inbound.ts`'s `dispatchWhatsappInboundEvent`
This is the plugin's core: it adapts a single `MetaWebhookEvent` into
OpenClaw's turn-kernel contract (`channelRuntime.inbound.run(...)`) via an
`ingest` / `resolveTurn` / `delivery` adapter object.
**Session key scheme.** `sessionKeyFor(sender)` produces
`agent:whatsapp:` — a per-sender session under the fixed
`AGENT_ID = "whatsapp"` agent (the `whatsapp` agent entry in
`openclaw/config/openclaw.base.json`, with its own dedicated
`~/.openclaw/workspace-whatsapp` workspace, distinct from the `main` agent's
workspace). Senders are validated by `SENDER_PATTERN` (`/^\+?\d+$/`) before
use.
**Access control.** `resolveWhatsappAccess` calls the SDK's shared
`resolveStableChannelMessageIngress` (the same helper the bundled
`extensions/sms` reference plugin uses), passing through
`cfg.channels["whatsapp-cloud"].dmPolicy`/`allowFrom`. The plugin then
enforces the resulting `senderAccess.allowed` decision itself, *before* ever
calling into the turn kernel — the code comment is explicit about why this
matters: Meta's test-tier WhatsApp numbers only allow pre-approved test
recipients to message at all, which masks the fact that a
production-verified Meta number has no equivalent gate, so this plugin
cannot rely on Meta to filter senders. `cfg.channels["whatsapp-cloud"].allowFrom`
entries are bare E.164-without-`+` digit strings (see
`openclaw/config/openclaw.base.json`'s `channels.whatsapp-cloud.allowFrom`);
`stripLeadingPlus` normalizes senders before matching.
**`dmPolicy` is a deployment choice, not a hard-coded restriction.** The SDK
(`dm-policy-shared.ts`) supports four modes, and this plugin doesn't assume
any one of them — a deployment picks the mode that fits its use case:
- `"allowlist"` — only senders in `allowFrom` get through. Right for a
personal bot restricted to specific numbers.
- `"open"` — anyone can message in (`allowFrom: ["*"]`), or an allowlist
still layers on top if `allowFrom` is set without `"*"`. This is the mode
for a genuinely public bot, and what this repo's own reference deployment
(`openclaw/config/openclaw.base.json` in `claw-infra`) uses as of
2026-07-20, after starting out on `"allowlist"` restricted to a single
verified number.
- `"pairing"` — new senders go through a self-service pairing flow instead
of the operator pre-approving every number; a public-but-not-anonymous
middle ground.
- `"disabled"` — no DMs accepted at all.
Anyone reusing this plugin for a public-facing bot sets `dmPolicy: "open"`
(or `"pairing"`) instead of `"allowlist"` — no plugin code changes needed;
`resolveWhatsappAccess` already routes through all four modes via the same
shared SDK helper.
**Tool grants: `sessions_spawn` is on by default, with one exception.** The
example config in the README grants the whatsapp agent `sessions_spawn`
alongside `cron` — enable both unless your deployment hits a specific,
uncommon failure mode: some Claude Code OAuth-backed model routes reject
requests with a 400 `"Third-party apps now draw from your extra usage, not
your plan limits"` error when `cron` and `sessions_spawn` are both present
in the same agent's tool schema at once, treating the combination as an
autonomous-agent-framework fingerprint. This isn't specific to this plugin
or to WhatsApp — it's a property of the model route, not the channel. If
your agent's model route rejects requests this way, drop `cron` or
`sessions_spawn` (whichever your agent needs less) from that agent's
`tools.allow` rather than switching model routes.
**The `ingest` / `resolveTurn` / `delivery` adapter shape.** OpenClaw's turn
kernel `await`s `adapter.ingest(raw)` first, then calls `resolveTurn(input)`
to build session/context state, then dispatches the reply through
`delivery`. This plugin's `ingest`:
- For `type: "text"` events, returns the text verbatim as
`rawText`/`textForAgent`/`textForCommands`.
- For `type: "audio"` events, downloads the voice note (§2.3) and
transcribes it *synchronously inside `ingest`* before returning, so the
agent's prompt is built from real transcribed text — see §2.4 for why this
step exists and is time-bounded.
- For `type: "image"` events, downloads the image (§2.3) and attaches it as
a native `media` fact; any caption becomes `rawText`.
- Any other/unrecognized event shape returns `null`, which the kernel treats
as a cleanly-dropped turn (no crash, no fabricated text).
- **Download failures are caught inside `ingest`, not left to propagate.**
Both the audio and image branches route their download call through
`downloadInboundMediaOrNotifyFailure`, which on a thrown error (e.g.
`meta-client.ts`'s `MAX_MEDIA_DOWNLOAD_BYTES` 20MB cap rejecting an
oversized file — confirmed live with a real 41MB voice note) logs a
warning, sends the sender a plain-text "couldn't process that
voice note/image" reply, and returns `null` to drop the turn cleanly.
Before this existed, the exception surfaced nowhere: no reply, no typing
indicator, total silence from the bot's side while Meta itself showed the
message as delivered.
`resolveTurn` builds the kernel's native inbound context via
`channelRuntime.inbound.buildContext(...)`, including the native `media`
attachment mechanism: for a voice note, `input.media` is set to an
`InboundMediaFact[]` (a `{path, contentType, kind: "audio", messageId,
transcribed}` object) referencing the locally-saved audio file. This is how
OpenClaw's own turn kernel (`applyMediaUnderstandingIfNeeded`) knows to
run — or skip — its own post-hoc transcription pass over the same
attachment; `transcribed: true` is only set once `ingest` already obtained a
real transcript, so the framework doesn't pay for (and doesn't
overwrite `Body` with) a redundant second Deepgram call.
**Forwarding/reply provenance → `supplemental`.** The same `buildContext`
call also passes `supplemental: buildSupplementalContext(event)`
(`inbound.ts`), mapping the provenance fields (forwarded/
frequentlyForwarded/quotedMessageId/quotedFrom, now nested under
event.provenance) parsed in §2.1 onto the SDK's native
`SupplementalContextFacts` shape — the same mechanism every OpenClaw channel
plugin uses for "this text isn't necessarily the sender's own words," not a
bespoke one:
- `event.provenance?.forwarded` → `supplemental.forwarded = { senderAllowed: true }`.
- `event.provenance?.quotedMessageId` → `supplemental.quote = { id, sender?, senderAllowed: true, isQuote: true }` —
id/sender only, since Meta never gives the quoted message's actual body.
- `event.provenance?.frequentlyForwarded` → an `untrustedContext` entry (`label:
"WhatsApp forwarding signal"`), since `SupplementalContextFacts.forwarded`
has no dedicated frequency field — that shape describes *who* forwarded
it, not *how often*.
`senderAllowed: true` is hardcoded for both, and this is a deliberate,
channel-specific judgment call, not a shortcut: whatsapp-cloud is a direct
1:1 surface (`conversation: { kind: "direct" }`), and `buildSupplementalContext`
only runs after `resolveWhatsappAccess` has already allowlist-checked
`event.sender` above — so `context.from` on a reply can only be that
already-vetted sender or the bot's own prior message, never an unvetted
third party. This would NOT be a safe default on a group-chat-capable
channel, where a quote could reference an arbitrary other participant.
Applies uniformly across text/audio/image (`event` is the original webhook
event, closed over from the outer function — not something rebuilt per
`ingest` branch), covered in `inbound.test.ts`'s "forwarding / reply
provenance" suite.
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#ede7f6', 'primaryBorderColor': '#5e35b1', 'primaryTextColor': '#311b92', 'lineColor': '#5e35b1', 'actorBkg': '#e0f2f1', 'actorBorder': '#00695c', 'actorTextColor': '#004d40', 'signalColor': '#5e35b1', 'signalTextColor': '#311b92'}}}%%
sequenceDiagram
autonumber
participant Meta as ☎️ Meta Cloud API
participant WH as 🔐 webhook.ts
participant IN as 🔌 inbound.ts (adapter)
participant TK as 🧠 turn kernel
participant DG as 🗣️ Deepgram STT
Meta->>WH: POST webhook (signed body)
WH->>WH: verify HMAC signature, cap 1MB, parse
WH->>IN: onEvent(MetaWebhookEvent)
WH-->>Meta: 200 {"status":"ok"} (fire-and-forget)
IN->>IN: resolveWhatsappAccess (access check per configured dmPolicy)
alt sender rejected by dmPolicy
rect rgb(255, 235, 238)
IN-->>IN: 🚫 rejected / paired-off, no turn started
end
else event.type == "audio"
IN->>Meta: downloadMedia(mediaId)
alt download fails (e.g. >20MB cap)
rect rgb(255, 235, 238)
IN->>Meta: ⚠️ sendText("couldn't process that voice note...")
IN-->>IN: ingest() returns null, turn dropped
end
else download succeeds
rect rgb(232, 245, 233)
IN->>DG: transcribeAudioFile (withDeadline 75s)
DG-->>IN: transcript
end
end
else event.type == "image"
IN->>Meta: downloadMedia(mediaId)
alt download fails
rect rgb(255, 235, 238)
IN->>Meta: ⚠️ sendText("couldn't process that image...")
IN-->>IN: ingest() returns null, turn dropped
end
else download succeeds
rect rgb(232, 245, 233)
IN->>IN: attach media fact (kind: "image"), caption -> rawText
end
end
end
IN->>Meta: 👀 markAsRead({messageId, typing: true})
IN->>TK: ingest() -> resolveTurn() -> run(...)
Note over TK: ⏱️ whole call wrapped in withDeadline(10 min)
TK->>TK: buildContext(media: [fact, transcribed?], supplemental: forwarded/quote provenance)
TK-->>IN: assembled reply payload
```
### 2.3 Text vs. image vs. audio handling differences
- **Text**: no download/transcription step; `rawText` flows straight from
the webhook payload into the turn.
- **Audio** (voice notes) and **inbound image** both download via the same
generic helper, `channel.ts`'s `downloadWhatsappCloudInboundMedia` (wired
in `inbound.ts` as `downloadVoiceNoteMedia`/`downloadImageMedia`
respectively) — it downloads the bytes from Meta via `meta-client.ts`'s
`downloadMedia` and saves them to the sandboxed managed-media directory
via `saveMediaBuffer(..., "inbound")`, handing back a local file `path`.
A local path (`MediaPath`), not a remote URL, is used deliberately: Meta's
media download URLs require the app's Bearer access token, and the
framework's own remote-media fetch for `MediaUrl` (`readRemoteMediaBuffer`)
does not support attaching custom auth headers.
- **Audio** additionally gets a synchronous transcription pass
(`transcribeVoiceNoteMedia`, §2.2) before the framework's own native
understanding pipeline runs, because that pipeline's `activeModel`
override is confirmed broken for the `"audio"` capability (see
`createWhatsappCloudVoiceNoteTranscriber`'s doc comment).
- **Image** gets none of that: OpenClaw's native image-understanding
pipeline honors `activeModel` correctly out of the box for the
`"image"` capability, so the downloaded file is just handed to the
turn as a native `media` fact (`kind: "image"`) with no bespoke
understanding step needed. Any caption Meta sends alongside the image
becomes the turn's `rawText`/`textForAgent`/`textForCommands`, same as
a plain text message; no caption yields an empty string.
- Outbound image generation (a distinct, unrelated tool) is covered in
§3.2.
### 2.4 Reply delivery — agent-driven, not automatic
**Current design.** Delivery of the agent's actual reply — text or voice —
is entirely agent-driven: the agent must explicitly call one of two tools
registered in `channel.ts`:
- `send_text_reply_for_whatsapp(to, text)` — sends a plain text reply via
`lazyMetaClient.sendText`.
- `send_voice_reply_for_whatsapp(to, text)` — synthesizes `text` via the
same Cartesia pipeline `sendWhatsappCloudVoiceReply` always used
(`stripMarkdownForSpeech` → Cartesia TTS → Meta upload) and sends it as a
voice note.
Both require an explicit `to` param rather than an inferred recipient:
confirmed against the installed `openclaw` package's
`AnyAgentTool`/`ErasedAgentToolExecute` type that a plugin-registered tool's
`execute(toolCallId, params, signal?, onUpdate?)` signature carries no
session/channel context at all (unlike the framework's own built-in
`message` tool, which has privileged access to
`toolContext.currentChannelId`). The workaround is reliable because every
turn's prompt already includes a "Conversation info" metadata block with
`sender_id`/`chat_id` set to the sender's phone number (confirmed live in
real trajectory data) — the agent copies that value verbatim.
Each tool call marks a per-sender flag in `reply-delivery-tracker.ts`
(`markReplySent`), a tiny module that exists specifically to give
`inbound.ts`'s `deliver()` — which handles the turn kernel's own plain
final-text reply, a completely separate code path from an explicit tool
call — visibility into "did an explicit send already happen this turn."
`resetReplySentFlag` clears it at the start of each turn (in
`dispatchWhatsappInboundEvent`, right after the access-control check),
avoiding a stale flag from a previous turn suppressing the current one.
**The fallback (not the primary mechanism).** `durable()` now always
defers text-only final replies to `deliver()` (never claims them for
`message-adapter.ts`'s unconditional `sendText` — only media payloads still
claim the durable path unconditionally, a separate, pre-existing case
unrelated to the agent's explicit `message`-tool image sends, which already
bypass this pair entirely). `deliver()` then checks
`wasReplySentThisTurn(sender)`:
- If true, the plain final text is dropped — it's leftover
wrap-up/scratch content, not a second reply to send.
- If false, the turn ended without an explicit send. Rather than leave the
user with silence and no error, `deliver()` sends the raw final text
anyway, but visibly flagged as a fallback (`"⚠️ Fallback reply (no
explicit send this turn):\n\n${text}"`) so it reads as distinct from a
deliberate reply.
```mermaid
flowchart TD
classDef agent fill:#e0f2f1,stroke:#00695c,color:#004d40
classDef success fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
classDef failure fill:#ffebee,stroke:#c62828,color:#7f0000
classDef warn fill:#fff8e1,stroke:#f9a825,color:#7f6003
A[🤖 agent turn running]:::agent -- "explicit tool call" --> B["send_text_reply_for_whatsapp /\nsend_voice_reply_for_whatsapp (channel.ts)"]:::agent
B --> C["markReplySent(sender)\n(reply-delivery-tracker.ts)"]:::agent
B --> D[✅ WhatsApp text or voice note]:::success
A -- "plain final text (kernel)" --> E["deliver() checks\nwasReplySentThisTurn(sender)"]:::agent
E -- "true: already sent" --> F[🗑️ dropped -- no double-send]:::failure
E -- "false: nothing sent" --> G["sendText: flagged fallback message"]:::warn
G --> H["⚠️ visibly marked as fallback,\nnot a normal reply"]:::warn
```
**Why not automatic conversion.** An earlier design had `durable()`
auto-convert every voice-originated turn's plain text reply to speech,
unconditionally, with no agent involvement — the exact inverse problem of
today's design: the agent had no way to ever choose text on a
voice-originated turn (or voice on a text-originated one). That design
itself replaced a *prior* bug where an unconditional `durable() → {to:
sender}` silently starved the voice-conversion branch entirely (a
plain-text reply on a voice-originated turn is a real production failure
mode either design has to guard against — this is why `deliver()`'s
fallback still exists even now that delivery is agent-driven: forgetting to
call a tool is a distinct, still-possible failure mode from either prior
bug, and needs its own guard).
**A retry/forced-tool-call design was considered and rejected.** Before
landing on the flagged-fallback approach, two cleaner alternatives were
researched and confirmed NOT available: (1) `toolChoice: "required"` — a
real, working model-API primitive (confirmed via the installed package,
even used internally by `openclaw doctor`'s model-capability probing) that
forces a model to call a tool before ending its turn — is not exposed
through any plugin-facing config surface (agent config, `resolveTurn`, tool
registration) for a real user-facing turn, only via a direct low-level
`complete()` call outside the normal agent-turn pipeline. (2) A
turn-continuation/rejection hook that would let a plugin re-prompt the
agent mid-turn if it produced no reply — no such hook exists either. Absent
either primitive, the flagged fallback in `deliver()` is the only
verifiable safety net available to a plugin.
**Control commands (`/reset`, `/new`, etc.) must bypass the fallback
entirely.** Confirmed live: `/reset`'s own reply ("✅ Session reset.")
initially came back wrapped in the fallback caption, which is actively
wrong — a control command is handled by the framework's command layer
*before* the agent ever runs (confirmed in the installed package:
`commands.runtime-*.js` returns `{shouldContinue: false, reply: {text:
...}}` directly), so there was never an agent turn in which
`send_text_reply_for_whatsapp`/`send_voice_reply_for_whatsapp` could have
fired. Two signals were considered and rejected before landing on the
right one: `resolveTurn`'s own second param (`ChannelEventClass`, which
does carry `canStartAgentTurn`) turned out to always be the default
(`canStartAgentTurn: true`) for this plugin, because it comes from
`adapter.classify?.(input) ?? DEFAULT_EVENT_CLASS` and this plugin
implements no `classify` method; and `deliver`/`durable`'s own `info` param
(`ChannelDeliveryInfo`) only carries `kind: "tool" | "block" | "final"`,
always `"final"` for both a real agent reply and a command reply — neither
distinguishes the two cases. The fix instead calls `hasControlCommand`
(`openclaw/plugin-sdk/command-detection`) directly on the raw inbound text
— the exact same detection function the framework's own command layer
uses (matches a message against the real registered command list's
`textAliases`, not a blanket "starts with `/`" check) — once per turn, and
`deliver()` skips both the tracker check and the fallback wrapper entirely
when it's true, delivering the command's reply as plain text.
### 2.5 Timeout/deadline protections — `timeout.ts`'s `withDeadline`
`withDeadline(promise, timeoutMs, label)` (`timeout.ts`) races an arbitrary
promise against a `setTimeout`-based timeout promise, rejecting with a
labeled error if the timeout wins, and always clears the timer in a
`finally`. The timer is `unref`'d so a pending one never keeps the process
alive.
**The incident that motivated it.** The old inbound voice-note path ran a
bespoke, inline transcription call (`await transcribeVoiceNote(...)`)
directly inside `ingest`. Every individual network leg inside that call
already had its own `AbortSignal.timeout` (Meta media download, Deepgram
STT request) — but nothing bounded the *composition* of those calls. In
production, this hung silently for **hours**, with no error and no timeout
firing. The reason it was invisible to OpenClaw's own stuck-session
watchdog is structural: that watchdog only monitors the phase *after* an
agent's embedded model-call run registers itself as active — and `ingest`
is a **pre-dispatch** phase that runs before the agent turn (and therefore
before the watchdog) is even engaged. A hang in `ingest` is a hang the
framework has no visibility into at all.
**Where `withDeadline` is applied today:**
- `inbound.ts`: the entire `channelRuntime.inbound.run(...)` call is wrapped
in a 10-minute (`DISPATCH_DEADLINE_MS`) deadline — defense-in-depth around
the *whole* dispatch chain, not just the voice leg, since any future hang
anywhere in the chain (a plugin bug, a hung tool call, a stuck delivery)
would be just as silent. 10 minutes comfortably exceeds the worst-case
225s latency of the slowest single tool call this plugin makes
(`image-tool.ts`'s image generation, see §3.2) plus room for a multi-step
agent turn.
- `inbound.ts`: the synchronous, pre-dispatch `transcribeVoiceNoteMedia`
call inside `ingest` is separately wrapped with a 75s
(`VOICE_TRANSCRIPTION_TIMEOUT_MS`) deadline — headroom above the
framework's own `cfg.tools.media.audio.timeoutSeconds` default (60s) for
its underlying `transcribeAudioFile` call, on the stated principle of
"never trust a single inner timeout to actually bound an `await` chain in
`ingest`." A failure or timeout here is non-fatal: it falls back to an
empty transcript and relies on the framework's own post-hoc
`applyMediaUnderstandingIfNeeded` pass (driven by the native `media` fact
set in §2.2) to fill in the transcript later.
- `channel.ts`: `sendWhatsappCloudVoiceReply` (the outbound TTS-and-send
sequence) wraps its Cartesia synthesis + Meta upload/send composition in a
100s (`VOICE_REPLY_TIMEOUT_MS`) deadline, for the same "individually
bounded legs, unbounded composition" reason.
**Keeping the typing indicator alive for the whole dispatch.** Meta clears
the "typing…" indicator automatically ~25s after it's shown, or once a
reply is actually sent, whichever is first. A turn can legitimately take
longer than that: confirmed live, streaming Cartesia's synthesized audio
for a long (~2000-character) reply alone took ~40s before the Meta upload
step even starts, and `generate_image_for_whatsapp` alone has a traced
225-260s worst case (§3.2). Past the ~25s mark WhatsApp shows no activity
indicator at all, and the exchange looks broken even though nothing failed
and no error was ever thrown. `inbound.ts`'s `keepTypingIndicatorAlive`
re-sends the typing indicator on a 20s interval (shorter than Meta's ~25s
auto-clear) for the ENTIRE dispatch — `ingest`, the agent turn, any tool
calls it makes (including `send_voice_reply_for_whatsapp`/
`send_text_reply_for_whatsapp`), and the kernel's own delivery — not just a
single reply-synthesis leg, stopping as soon as the whole dispatch settles
(success or failure). Best-effort (a failed refresh is logged and ignored,
same as the initial `markAsRead` call), never blocking or failing the
actual dispatch.
Note on why `ingest` also transcribes synchronously rather than relying
solely on the framework's post-hoc patch: a live production turn returned a
bare `NO_REPLY` for a real "hello how are you" voice note, even though the
transcript displayed correctly in the Control UI's session view afterward.
Tracing the installed package's prompt-construction path did not turn up a
proven race, but rather than rest on an unproven-safe framework internal,
`ingest` now gets an actual transcript itself before ever building the
turn's `rawText`/`textForAgent`.
## 3. Outbound flow
### 3.1 Text replies — `message-adapter.ts`
`createWhatsappMessageAdapter` (`message-adapter.ts`) implements OpenClaw's
`ChannelMessageAdapter` contract (`defineChannelMessageAdapter`), advertising
`durableFinal.capabilities: { text: true, media: true,
messageSendingHooks: true }`. `send.text` calls `metaClient.sendText` and
wraps the result via `createMessageReceiptFromOutboundResults`. This is the
path exercised when the turn kernel's `durable()` claims a reply (§2.4) —
i.e. every ordinary text reply, and also whenever the agent explicitly calls
the framework's built-in `message` tool.
`send.media` calls `resolveImageBytesAndMimeType` (shared with §3.2's
outbound-media bridging in `channel.ts`) then `metaClient.sendImage`.
`resolveImageBytesAndMimeType` fetches a remote `mediaUrl` with a 30s
timeout and a 20MB size cap (checked against both `content-length` and the
actual downloaded byte count, since `content-length` can be absent or
wrong).
### 3.2 Image generation
**Generation — `image-tool.ts`'s `generateImageForWhatsapp`.** POSTs to
LiteLLM's `/images/generations` (`{model: "pollinations-image", prompt,
response_format: "b64_json"}`), authenticated with the gateway's LiteLLM
master key. The client-side `AbortSignal.timeout` is set to **260s** —
deliberately larger than the traced 225s worst case of LiteLLM's full
server-side fallback chain (45s for `pollinations-image` + 180s for its
`ai-horde-image` fallback; see §5), so a slow-but-succeeding server-side
fallback can't be discarded by a premature client-side abort. Both current
backing providers always populate `b64_json` themselves; a response with
only a hosted `url` is treated as an error (`"LiteLLM returned no image"`)
since the backing providers this plugin was built against never actually
return that shape.
*Provider choice:* other image-generation providers were evaluated first and
didn't pan out for account/entitlement reasons specific to this deployment,
not because of anything wrong with this plugin's request shape. The plugin
settled on **Pollinations.ai** (primary) — a free, unauthenticated,
synchronous `GET image.pollinations.ai/prompt/...` endpoint — with
**AI Horde** (a free, crowdsourced-worker,
submit-then-poll API) as fallback. Both are wired into LiteLLM as custom
providers (see §5).
**Delivery — `channel.ts`'s `generate_image_for_whatsapp` tool.** Registered
via `api.registerTool(...)` in `registerFull`, and allow-listed for the
`whatsapp` agent only (`openclaw.base.json`'s `agents.list[].tools.allow`).
Its `execute` closure calls `generateImageForWhatsapp`, then — critically —
saves the returned base64 bytes to the sandboxed managed-media directory via
`saveMediaBuffer(buffer, contentType, "tool-whatsapp-image-generation")` and
hands the agent back only a short file **path**, never the raw base64. The
subdir is prefixed `"tool-"` because `saveMediaBuffer`'s sandboxing
(`isManagedMediaPathUnderRoot`) only accepts the fixed `"outbound"` subdir or
anything `"tool-"`-prefixed, mirroring OpenClaw's own bundled
`image_generate` tool's `"tool-image-generation"` convention.
*Why not hand the agent raw base64:* This was the original design and it
produced a confirmed production bug: asking an LLM to reproduce tens of
thousands of characters of base64 verbatim across `message`-tool retries is
fundamentally unreliable. In production this degraded, after a couple of
retries, into a hallucinated ~200-byte placeholder PNG standing in for the
real ~33KB image. The fix is the save-to-sandbox-then-path-reference
pattern described above — the tool's result text explicitly
instructs the agent to reuse the exact path string verbatim and never pass
`buffer`/`contentType` or attempt to reconstruct base64. This same
path-not-payload pattern is reused for the *inbound* voice-note leg (§2.3).
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#ede7f6', 'primaryBorderColor': '#5e35b1', 'primaryTextColor': '#311b92', 'lineColor': '#5e35b1', 'actorBkg': '#fff3e0', 'actorBorder': '#ef6c00', 'actorTextColor': '#e65100'}}}%%
sequenceDiagram
autonumber
participant Agent as 🤖 whatsapp agent
participant Tool as 🎨 generate_image_for_whatsapp
participant LL as 🧪 LiteLLM /images/generations
participant PO as 🌸 pollinations-image
participant AH as 🐴 ai-horde-image (fallback)
participant Sandbox as 💾 saveMediaBuffer
participant MsgTool as 📤 message tool
participant MC as ☎️ meta-client.ts
Agent->>Tool: execute({prompt})
Tool->>LL: POST /images/generations (b64_json, timeout 260s)
LL->>PO: dispatch (timeout 45s, num_retries 0)
alt Pollinations fails
rect rgb(255, 243, 224)
LL->>AH: fallback (timeout 180s, num_retries 0)
AH-->>LL: b64_json
end
else Pollinations succeeds
rect rgb(232, 245, 233)
PO-->>LL: b64_json
end
end
LL-->>Tool: imageBase64, contentType
Tool->>Sandbox: saveMediaBuffer(bytes, contentType)
Sandbox-->>Tool: {path}
Tool-->>Agent: "saved to {path}, call message tool with path=..."
Agent->>MsgTool: send(action=send, path, message)
MsgTool->>MC: sendImage
MC-->>MsgTool: ✅ WhatsApp image message delivered
```
*Why not OpenClaw's native `image_generate` tool:* it fully supports LiteLLM as a
provider (`litellm/`, e.g. `litellm/pollinations-image`) with a per-call
`model` override, so no separate LiteLLM integration would technically be
needed. It was deliberately not used, though, because it's asynchronous —
OpenClaw queues a background task, returns immediately, and later "wakes" the
requester's session with a `task_completion` internal event, expecting the
completion agent to call the `message` tool once woken. For a webhook-driven
channel like this one (no live connection to hold open across that gap), the
reliability of that wake genuinely depends on OpenClaw's own durable-delivery
machinery for it -- and as of this writing that machinery is still being
actively hardened: a real fix for it
(`fix(agents): wake owning session after generated-media direct delivery`,
commit `15a304d6`, 2026-07-15) exists only in the `v2026.7.2-beta.2`
prerelease, not yet in the latest stable `v2026.7.1` (2026-07-13) -- confirmed
via `gh api repos/openclaw/openclaw/compare/v2026.7.1...15a304d6` returning
`diverged` and the same compare against the beta tag returning `behind`. The
installed OpenClaw version this plugin targets (2026.5.27) predates all of
this hardening by about two months, so it would rely on an even thinner
version of the same mechanism (a text-only failure fallback, no durable
queue, no retry). Revisit this once the durable handoff lands in a stable
release; the synchronous custom-tool path here isn't a workaround for a
missing feature, it's what's actually appropriate given what's stable today.
### 3.3 Voice replies
- **Invocation — `channel.ts`'s `send_voice_reply_for_whatsapp` tool.**
`sendWhatsappCloudVoiceReply` (the actual synth-and-send function
described below) is unchanged from earlier designs, but it's no longer
called automatically from `inbound.ts`'s `deliver()` — see §2.4. It's now
invoked only via an explicit tool call the agent makes
(`send_voice_reply_for_whatsapp(to, text)`), which also marks
`reply-delivery-tracker.ts`'s per-sender flag so `deliver()`'s fallback
knows a reply was already sent.
- **STT — `speech.ts`'s `createDeepgramClient`.** A TypeScript port of the
old bridge's proven `whatsapp-bridge/speech.py` Deepgram wiring (same
endpoint, headers, default `nova-3` model). As of the current code, this
client is **not** actually called for the inbound leg anymore — that role
moved to the framework's own `transcribeAudioFile` (§2.2–2.4) — but the
module remains as the STT building block and is still covered by tests.
- **TTS — `cartesia.ts`'s `createCartesiaClient`.** Calls Cartesia's
`POST /tts/bytes` (`Cartesia-Version` header pinned to a concrete date,
not `"latest"`, so a future Cartesia default bump can't silently change
the request/response shape), always requesting an mp3 container so
`meta-client.ts`'s `sendAudioBytes` can upload it straight through.
Replaced an earlier Deepgram-TTS design specifically to match how this
stack's *other* voice feature — the real-time LiveKit voice agent
(`livekit/agent/agent.py`) — already does TTS in production
(`TTS_PROVIDER=cartesia`). There is no native OpenClaw TTS abstraction for
Cartesia (the framework's own `speech-core`/`talk-voice` TTS layer only
supports `openai`/`microsoft`/`elevenlabs`), so this stays a custom
client, wrapped in `withDeadline` (§2.5) so it can't repeat the inbound
leg's hang failure mode.
- **`markdown-strip.ts`'s `stripMarkdownForSpeech`.** A deterministic,
regex-based (not AST-based) stripper applied to the agent's reply text
right before TTS synthesis (`channel.ts`'s `sendWhatsappCloudVoiceReply`).
It exists because the agent's raw reply text is written for a
markdown-rendering surface (WhatsApp-native markdown per
`openclaw/workspace-whatsapp/AGENTS.md`'s Formatting section) — left
unstripped, Cartesia would read stray asterisks, "hash hash hash" for
headers, and "one dot" before numbered-list items. It handles code
fences/inline backticks, `[text](url)` links, `#`-headers, `---`
dividers, bullet/numbered list markers, and bold/italic emphasis, with
careful `[ \t]`-vs-`\s` regex choices documented inline to avoid eating
blank lines. This is a code-level backstop *alongside* (not instead of) a
prompt-level instruction in `AGENTS.md` telling the agent to write plain
spoken prose for voice-originated turns. `AGENTS.md`'s voice-reply bullet
also caps voice replies to 2-4 spoken sentences, unconditionally — even
when the user explicitly asked for a detailed explanation (the general
"Reply length" bullet's exception for detailed requests is deliberately
overridden for voice replies specifically). This exists because a real
production reply came back as a ~2000-character structured explanation,
which — even though `stripMarkdownForSpeech` and delivery both worked
correctly — synthesized into a ~3-minute voice note, a bad experience
regardless of whether the pipeline succeeds. There is no length
enforcement at the code layer for this (no truncation, no character cap in
`channel.ts`) — it's a prompt-level instruction only, since truncating a
long answer mid-sentence would be worse than a long-but-complete one; the
agent is instead told to offer a written follow-up for genuinely detailed
topics rather than speak the whole thing.
- **STT model/language and TTS language.** `WHATSAPP_STT_MODEL` (default
`"nova-3"`), `WHATSAPP_STT_LANGUAGE` (default `"ar"`), and
`WHATSAPP_TTS_LANGUAGE` (default `"ar"`) are deliberately separate env vars
from the LiveKit voice agent's own config, all resolved in `channel.ts`
(`resolveWhatsappSttModel`/`resolveWhatsappSttLanguage`/`resolveWhatsappTtsLanguage`):
- STT went through several real production incidents before landing on the
current design. Attempt 1, a hardcoded `language=ar`, fixed Arabic speech
but mis-transcribed a user's mid-conversation switch to English as
garbled Arabic-script phonetic transliteration (Deepgram forces every
word into the one specified language's script/vocabulary when a single
language is pinned). Attempt 2, Nova-3's `language=multi` code-switching
mode, was chosen to fix that — but was confirmed, via Deepgram's own
docs, to have the *opposite* gap: `multi`'s documented language list
(English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese,
Italian, Dutch) does not include Arabic at all, so Arabic speech under
`multi` was forced into the closest-sounding phonetics of one of those
ten languages instead — confirmed live via a real voice note transcribed
as nonsense ("walit surat makuk fadai al akaukab maris"). Nova's plain
`detect_language=true` auto-detect was independently confirmed not
viable either — Arabic is absent from that feature's 35 supported
detection languages too. **No Deepgram Nova mode does real Arabic+English
code-switching or auto-detection.** Attempt 3 switched the primary model
to Deepgram's Whisper Cloud (`whisper-large`) with no forced language, so
Whisper's own ~100-language identification would run per message. A
direct, real side-by-side comparison later disproved this as the right
*primary* choice: replaying the same real production audio (confirmed
pure Arabic speech) through both models showed `nova-3`+`language=ar`
producing a correct Arabic-script transcript with word confidences of
0.60–0.99 (`"مرحبا كيف حالك شو الاخبار"`), while `whisper-large`'s
auto-detection on the exact same file produced an English-lettered
transliteration with confidences of only 0.05–0.61 (`"marhaba how are
you what's up"`) — Whisper had defaulted to English decoding instead of
detecting Arabic. Since this user's real usage is predominantly pure
Arabic, the current design (attempt 4) makes `nova-3`+`language=ar` the
PRIMARY attempt and demotes Whisper to an automatic fallback (see below)
for the English/mixed-language case attempt 1 originally broke.
`WHATSAPP_STT_LANGUAGE`, if explicitly set, overrides the primary
model's forced language only — the Whisper fallback always runs with no
forced language, since forcing one would defeat the reason it exists.
- **Direct Deepgram calls, not `transcribeAudioFile` — a real framework
bug, not a style choice.** `createWhatsappCloudVoiceNoteTranscriber`
(`channel.ts`) calls Deepgram DIRECTLY via `speech.ts`'s
`createDeepgramClient`, not the framework's own
`transcribeAudioFile`/`activeModel` mechanism the plugin originally used.
That switch exists because the original approach never actually worked:
`transcribeAudioFile`'s `activeModel.model` override is silently IGNORED
for the `"audio"` capability in the installed `openclaw` package —
confirmed by reading the installed `resolveActiveModelEntry`
(`runner-*.js`), which for `capability === "audio"` unconditionally calls
`resolveDefaultMediaModelFromRegistry` instead of honoring
`params.activeModel?.model` (the `"image"` capability branch, by
contrast, does honor an explicit override). This was proven, not
assumed, by pulling the actual request log from Deepgram's own
Management API (`GET /v1/projects/{id}/requests`) for the exact
timestamps of real in-app transcription failures during the Whisper-primary
period: every single request showed `path: "/v1/listen?model=nova-3"`,
never `whisper-large`, despite the plugin passing `activeModel:
{provider: "deepgram", model: "whisper-large"}` on every call. So the
empty-transcript failures investigated at that point were never actually
a Whisper problem at all — they were `nova-3` calls silently missing the
requested model override, with no `language` override either. Calling
Deepgram directly is the only way to actually control which model this
plugin's own STT attempts use, independent of the registry default
(`nova-3`) that `transcribeAudioFile` always falls back to for audio.
The framework's own post-hoc `applyMediaUnderstandingIfNeeded` pass
(§2.2) is unaffected by this and still always uses the registry default
regardless.
- **Retry/fallback ladder.** `createWhatsappCloudVoiceNoteTranscriber` does
not call Deepgram once and stop: it makes up to two primary-model
attempts (`nova-3`+`language=ar` by default, a real gap between them,
`STT_RETRY_DELAY_MS` = 3s), and if both fail (or return an
empty/whitespace-only transcript, which Deepgram can return with a
technically-successful response), a third and final attempt using
`whisper-large` with no forced language — a better safety net for an
English or heavily code-switched voice note than retrying the fixed
`language=ar` primary a second time, which would just get the wrong
language again. All three attempts together stay well inside
`VOICE_TRANSCRIPTION_TIMEOUT_MS` (75s, §2.5).
- TTS defaults to a fixed `"ar"` because Cartesia has no auto-detect
concept — a spoken reply must commit to one language per call, unlike
STT's per-message detection. The env vars are named distinctly
(`WHATSAPP_STT_MODEL`/`WHATSAPP_STT_LANGUAGE`/`WHATSAPP_TTS_LANGUAGE`)
from the LiveKit voice agent's own
`STT_LANGUAGE`/`CARTESIA_MODEL`/`CARTESIA_VOICE_ID`
(`livekit/agent/agent.py`) so the two independent voice features'
config can never silently collide, even though they share the same
underlying `CARTESIA_API_KEY` account.
```mermaid
flowchart LR
classDef core fill:#ede7f6,stroke:#5e35b1,color:#311b92
classDef ext fill:#fff3e0,stroke:#ef6c00,color:#e65100
classDef success fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20
A["🤖 agent final reply text\n(voice-originated turn)"]:::core --> B["✂️ stripMarkdownForSpeech\n(markdown-strip.ts)"]:::core
B --> C["🗣️ Cartesia POST /tts/bytes\nlanguage=WHATSAPP_TTS_LANGUAGE (default ar)"]:::ext
C --> D[🔊 mp3 audio bytes]:::ext
D --> E["📤 meta-client.ts sendAudioBytes\n(upload then reference)"]:::core
E --> F[✅ WhatsApp voice note to user]:::success
C -. "wrapped in withDeadline 100s" .-> C
```
## 4. Meta Graph API client — `meta-client.ts`
`createMetaClient({ accessToken, phoneNumberId, graphApiVersion?,
maxMediaDownloadBytes? })` wraps the WhatsApp Cloud API
(`graph.facebook.com//{phoneNumberId}/...`, defaulting to
`v21.0` — `channel.ts` sources `graphApiVersion` from
`WHATSAPP_GRAPH_API_VERSION`, letting a deployer pick up a newer Graph API
version without a code change once Meta eventually sunsets `v21.0`):
- `sendText` / `sendAudio` (by pre-uploaded media id) / `sendImage`
(upload-then-reference) / `sendAudioBytes` (upload-then-reference, used
for synthesized TTS voice replies) — all via a shared `post` helper that
posts to `.../messages`.
- `downloadMedia(mediaId)` — the two-step Graph API flow (resolve a
short-lived signed URL via `GET /{media-id}`, then `GET` that URL, both
requiring the Bearer token) ported from the old bridge's
`media_client.py`.
- `markAsRead({ messageId, typing })` — marks a message read and optionally
shows the "typing…" indicator; Meta clears the indicator automatically
once a reply is sent or after ~25s, so there's no explicit "stop typing"
call.
**Hardening**, consistent across every method: every network call has its
own 30s `AbortSignal.timeout`; `downloadMedia` enforces a 20MB cap
(`MAX_MEDIA_DOWNLOAD_BYTES`) checked against both `content-length` and the
actual downloaded length (mirroring the same content-length-can-lie defense
used in `message-adapter.ts`); non-`ok` responses are surfaced as errors
including the response body text for diagnosability; a missing message id
in a successful-status response is still treated as an error rather than
silently returning an empty result.
## 5. What the plugin assumes about its LiteLLM backend
This plugin doesn't ship or depend on any specific image-generation
provider — that's entirely the deploying operator's choice (a paid DALL-E
deployment, a single provider with no fallback, a custom `CustomLLM`
handler, whatever). All `image-tool.ts` requires is a LiteLLM instance
exposing an OpenAI-compatible `/images/generations` endpoint at
`config.baseUrl`/`config.model` (`litellm_params.model`), reachable within
its own client-side timeout budget. `channel.ts` sources these from
`LITELLM_BASE_URL`/`LITELLM_API_KEY`/`WHATSAPP_IMAGE_GENERATION_MODEL`
(the last defaults to this project's own reference deployment's
`pollinations-image` model name, but is fully overridable — see README's
env var table).
**The one real contract: a 260s round-trip budget.** `image-tool.ts` sets
`AbortSignal.timeout(260_000)` on its request — see that file's own doc
comment for the full worked-through math. Whatever backend a deployer
points this at (a single fast provider, or a multi-hop fallback chain),
it needs to either succeed or hand back a definitive failure within that
window; the plugin doesn't retry or fall back on its own past that point.
Reference-deployment specifics (which providers, their individual
timeouts, LiteLLM router quirks worked around to hit that budget) live in
`claw-infra`'s own `litellm/config.yaml` and the standalone
`litellm-free-image-providers` package — that's this project's own
infrastructure choice, not something every user of this plugin needs to
replicate.
## 6. Deployment
- **Install mechanism.** `compose/compose.platform.yml`'s `openclaw` service
bind-mounts `openclaw/plugins/whatsapp-cloud` read-write (not `:ro` — this
matters, see below) into the container, and its startup command chain
runs `openclaw/scripts/bootstrap_openclaw_config.mjs` (as root, before
`su`-ing into the `node` user) which calls `ensureWhatsappCloudPlugin`.
That function runs `npm ci --omit=dev --no-audit --no-fund` in the
plugin directory, then `openclaw plugins install --link ` —
`--link` (not a copy-install) so the gateway loads the plugin directly
from the bind-mounted path, and deliberately without `--force` (the CLI
rejects `--link --force` outright; re-running `install --link` on an
already-linked plugin is itself idempotent).
- **`npm ci`-on-every-boot with stamp-file skip.** `node_modules` is
gitignored, so a fresh checkout has none — hence the bind mount being
read-write and `npm ci` running at container start rather than baked into
the image. But re-running `npm ci` on *every* plain restart (host reboot,
`docker compose restart`) is wasted work that also adds an npm-registry
network dependency to routine restarts — and if the registry happens to
be unreachable at that moment, an aborted install would crash-loop the
whole gateway. `isInstallUpToDate`/`writeInstallStamp` (in
`bootstrap_openclaw_config.mjs`) hash `package-lock.json` and write that
hash to a stamp file (`.npm-ci-stamp`) inside `node_modules` after a
successful install; a matching stamp on a later boot skips `npm ci`
entirely, while any real lockfile change (a dependency bump, or the very
first boot) still forces a real install.
- **Install-before-config-write ordering.** The plugin must be linked
*before* the managed `openclaw.json` config (which includes
`channels.whatsapp-cloud`) is written to disk: `openclaw plugins install
--link` validates whatever config is currently persisted, and rejects any
`channels.` entry referencing a not-yet-linked plugin
("unknown channel id"). Getting this order wrong would make every
subsequent `openclaw` invocation — including the install step itself on
future boots — fail config validation and crash-loop the gateway.
`main()` in `bootstrap_openclaw_config.mjs` installs against the
pre-existing on-disk config first, then re-reads it (now carrying the
plugin's `plugins.load.paths` entry) before merging in the managed
config and writing the final file.
- **Force-recreation on config change.** `compose.platform.yml` bind-mounts
`openclaw/config/openclaw.base.json`, the bootstrap script itself, and
`openclaw/workspace-whatsapp` read-only into the `openclaw` container, and
bind-mounts `litellm/config.yaml`/`litellm/bootstrap/` read-only into the
`litellm` container. None of these are baked into their respective
images, so a plain `docker compose up -d` does not recreate either
container just because the mounted file content changed on disk — this
was confirmed live (a `config.yaml` update sat unloaded on a 3-day-old
running `litellm` process until force-recreated). `scripts/deploy.sh`
works around this explicitly: it diffs `openclaw/` and `litellm/` between
the old and new `HEAD` after `git pull`, and if either changed, runs
`docker compose ... up -d --force-recreate openclaw` /
`--force-recreate litellm` respectively, in addition to the normal
`up -d --remove-orphans` pass.
- **`AGENTS.md` size vs. the framework's bootstrap-injection char limit.**
`openclaw/workspace-whatsapp/AGENTS.md` is auto-loaded and injected into
the `whatsapp` agent's context on every turn by the framework's own
workspace-bootstrap convention (unrelated to this plugin's code — tied to
the `whatsapp` agent's `workspace` field in `openclaw.base.json`), but the
framework caps injected bootstrap-file content at `bootstrapMaxChars`
(default 12,000, confirmed via a real gateway log line: `workspace
bootstrap file AGENTS.md is 12141 chars (limit 12000); truncating in
injected context`). This is a real, silent-if-unnoticed failure mode: the
file grew past that default limit, and the truncation landed mid-sentence
inside the "Media" bullet's image-sending instructions, with the entire
closing section never injected at all. Fixed by setting
`"bootstrapMaxChars": 20000` on the `whatsapp` entry in
`agents.list[]` (`openclaw.base.json`) — a per-agent override
(`resolveBootstrapMaxChars` in the installed package checks the agent's
own config before falling back to `agents.defaults.bootstrapMaxChars`,
then the 12,000 default), rather than trimming file content. Worth
re-checking this limit any time `AGENTS.md` grows further.
## 7. Known limitations / not-to-re-litigate
- **Agent-driven delivery (§2.4) trades code-enforced predictability for
LLM judgment.** The agent can, and occasionally will, pick text when
voice was expected or vice versa, or forget to call a reply tool
altogether (caught by `deliver()`'s flagged fallback, but the fallback
itself is a visibly-imperfect experience, not a silent recovery). This
was a deliberate, discussed trade-off (see §2.4's rejected
`toolChoice: "required"`/retry-hook alternatives) — not something to
"fix" by reverting to fully automatic conversion, which has its own
confirmed failure mode (the agent can never choose otherwise).
- **The `message` tool can still send plain text, bypassing
`reply-delivery-tracker.ts` entirely.** `AGENTS.md` explicitly instructs
the agent to only use `message` for images, but nothing at the code layer
prevents a plain-text `message` call from slipping through unmarked —
if that happens, `deliver()`'s fallback would still fire afterward
(since `wasReplySentThisTurn` never got set), producing a duplicate
reply. Not yet observed live; worth watching for.
- **No Deepgram Nova mode does real Arabic+English code-switching or
detection** — confirmed via Deepgram's own docs, not assumed: Nova-3's
`multi` code-switching mode's language list excludes Arabic, and Nova's
`detect_language=true` auto-detect's supported-language list excludes
Arabic too. This is why a fully mixed-language voice note relies on the
`whisper-large` fallback rather than any single Nova mode (see §3.3) — not
a plugin-layer bug, a genuine gap in Nova's language coverage.
- **`transcribeAudioFile`'s `activeModel.model` override is broken for the
`"audio"` capability** in the installed `openclaw` package (confirmed by
reading the installed `resolveActiveModelEntry` and by Deepgram's own
request logs, see §3.3) — worth re-checking against future `openclaw`
package upgrades in case it's fixed upstream, since reverting to the
framework's own transcription mechanism would remove a chunk of bespoke
code in `channel.ts`.
- **A fixed `language=ar` primary will mis-transcribe fully-English or
heavily code-switched voice notes** on its first two attempts before the
`whisper-large` fallback catches it on the third — an intentional
trade-off (see the STT model/language bullet in §3.3) favoring accuracy on
this user's predominant pure-Arabic use case over first-attempt latency on
the less common English/mixed case, not an oversight.
- **Dialectal/colloquial Arabic ASR accuracy** is a separate, real
upstream-model-quality limitation independent of the language-detection
fix above — not something to re-solve at the plugin layer.
- **Documents and video are not supported.** `parseMetaWebhookPayload` only
recognizes `text`/`audio`/`image` inbound message types; there is no
outbound document/video delivery path either. `AGENTS.md` tells the agent
to describe such things in words instead.
- **AI Horde fallback latency is inherently variable.** Anonymous-tier jobs
are low scheduling priority and can take minutes; the 180s deployment
timeout and the handler's own 170s poll ceiling are tuned around observed
live behavior, not a hard SLA from the provider.
- **`speech.ts`'s Deepgram STT client is currently unused for the live
inbound path** (transcription now goes through the framework's own
`transcribeAudioFile`/bundled `deepgram` extension instead, per §2.2–2.4)
but is kept in the tree, tested, and still a valid building block — this
is intentional, not dead code to be pruned reflexively.