Status: production-ready via WhatsApp Web (Baileys). The gateway owns the linked session(s); there is no separate Twilio WhatsApp channel.

Setup

Install

apus onboard and apus channels add --channel whatsapp prompt to install the plugin the first time you select it; apus channels login --channel whatsapp offers the same install flow if the plugin is missing. Dev checkouts use the local plugin path; stable/beta installs @openclaw/whatsapp from npm first, then falls back to its declared ClawHub package only when the npm target is unavailable. The WhatsApp runtime ships outside the core Apus npm package, so its runtime dependencies stay with the external plugin. Manual install:

apus plugins install @openclaw/whatsapp

Use npm:@openclaw/whatsapp or clawhub:@openclaw/whatsapp to force a source; pin an exact version only for a reproducible install.

Pairing

Default DM policy is pairing for unknown senders.

Channel troubleshooting

Cross-channel diagnostics and repair playbooks.

Gateway configuration

Full channel config patterns and examples.

Quick setup

Configure access policy

{
  channels: {
    whatsapp: {
      dmPolicy: "pairing",
      allowFrom: ["+15551234567"],
      groupPolicy: "allowlist",
      groupAllowFrom: ["+15551234567"],
    },
  },
}

Link WhatsApp (QR)

apus channels login --channel whatsapp
Login is QR-only. On remote or headless hosts, have a reliable path to deliver the live QR to the phone before starting login; terminal-rendered QRs, screenshots, or chat attachments can expire in transit.

For a specific account:
apus channels login --channel whatsapp --account work
To attach an existing/custom auth directory before login:
apus channels add --channel whatsapp --account work --auth-dir /path/to/wa-auth
apus channels login --channel whatsapp --account work

Start the gateway

apus gateway

Approve the first DM access request (pairing mode)

Open **Settings → Channels → DM access requests**, find the WhatsApp account,
and approve the sender. If you prefer the CLI:
apus pairing list whatsapp
apus pairing approve whatsapp <CODE>
DM access requests expire after 1 hour; pending requests are capped at 3 per
account. This approval is separate from the WhatsApp login QR used to link the
account itself.

A separate WhatsApp number is recommended (setup and metadata are optimized for it), but personal-number/self-chat setups are fully supported.

Deployment patterns

Dedicated number (recommended)
- separate WhatsApp identity for Apus
- clearer DM allowlists and routing boundaries
- lower chance of self-chat confusion

```json5
{
  channels: {
    whatsapp: {
      dmPolicy: "allowlist",
      allowFrom: ["+15551234567"],
    },
  },
}
```
Personal-number fallback
Onboarding supports personal-number mode and writes a self-chat-friendly baseline: `dmPolicy: "allowlist"`, `allowFrom` including your own number, `selfChatMode: true`. See [Personal-number and self-chat behavior](/channels/whatsapp#personal-number-and-self-chat-behavior) for admission and safeguard rules.

Runtime model

Call the current requester with MeowCaller (experimental)

The plugin can expose whatsapp_call in WhatsApp-originated agent turns. It uses MeowCaller to place a WhatsApp voice call to the current authorized requester and play an Apus TTS message after they answer. The tool has no destination-number parameter, so a prompt cannot redirect the call. Disabled by default.

MeowCaller is experimental, has no tagged release, and uses a separately paired whatsmeow linked-device session — it cannot reuse the plugin's Baileys credentials. Pairing adds another linked device to the same WhatsApp account; scan with the identity used by Apus. Personal-number/self-chat mode cannot call itself; use a dedicated Apus number to call your personal number.

Enable experimental calls

Add `actions.calls: true` to the WhatsApp channel config. Changes follow [hot reload](/gateway/configuration/hot-reload):
{
  "channels": {
    "whatsapp": {
      "actions": {
        "calls": true
      }
    }
  }
}
When absent or `false`, Apus does not expose the `whatsapp_call` tool.

Install the reviewed MeowCaller CLI

The adapter expects a `meowcaller` executable on the gateway host's `PATH`. Until [MeowCaller PR #7](https://github.com/purpshell/meowcaller/pull/7) merges, build the reviewed branch:
git clone --branch feat/send-only-notify https://github.com/steipete/meowcaller.git
cd meowcaller
git checkout 752050471fc2bf7a8cdfbf7dbd3cd4e865d85d3f
mkdir -p "$HOME/.local/bin"
go build -o "$HOME/.local/bin/meowcaller" ./cmd/meowcaller
Ensure `$HOME/.local/bin` is on the gateway service's `PATH`; restart the Gateway if you changed its service environment. This revision has explicit `pair` and send-only `notify` commands; `notify` opens no microphone, speaker, video device, or diagnostic capture. Do not substitute the upstream example CLI's `play` command.

Pair the MeowCaller linked device

Ask the WhatsApp agent to check call setup (`whatsapp_call` status action reports the account-specific state directory and pairing command). For the default account:
state_dir="$HOME/.apus/credentials/whatsapp-calls/default"
mkdir -p "$state_dir"
chmod 700 "$state_dir"
meowcaller pair --store "$state_dir/wa-voip.db"
Run this interactively, scan the QR from **WhatsApp > Linked devices**, and wait for `MeowCaller linked device ready`. Keep `wa-voip.db` private — it is the MeowCaller session. Non-default accounts get their own store path from the status action; on Windows, run its PowerShell command.

Configure TTS and call from WhatsApp

Configure a telephony-capable [TTS provider](/tools/tts), then send a request such as `Call me and say the build finished.` The tool resolves the sender from trusted inbound context, synthesizes a temporary private WAV file, runs MeowCaller for a bounded call window, and deletes the audio file afterward. Apus passes the account's store explicitly, waits for a zero exit status after answer/playback/hangup, and treats a timeout or nonzero exit as a failed tool call.

Limits: one-to-one outbound audio calls only, no arbitrary destination numbers, no shared auth with the chat connection, no self-calls from personal-number/self-chat mode, synthesized audio capped at 60 seconds, no handset-side audibility receipt beyond MeowCaller's answer/playback/hangup completion, and Apus stops the companion process after a bounded 115-175 second window (covering MeowCaller's connection, answer, playback, and shutdown phases).

Approval prompts

WhatsApp can render exec and plugin approval prompts as 👍/👎 reactions, controlled by the top-level approval forwarding config:

{
  approvals: {
    exec: {
      enabled: true,
      mode: "session",
    },
    plugin: {
      enabled: true,
      mode: "targets",
      targets: [{ channel: "whatsapp", to: "+15551234567" }],
    },
  },
}

approvals.exec and approvals.plugin are independent; enabling WhatsApp as a channel only links the transport and sends nothing unless the matching approval family is enabled and routed there. Session mode delivers native emoji approvals only for approvals that originate from WhatsApp. Target mode uses the shared forwarding pipeline for explicit targets and does not create separate approver-DM fanout.

WhatsApp approval reactions require explicit approvers in allowFrom (or "*"). defaultTo sets ordinary default message targets, not an approver list. Manual /approve commands still pass the normal WhatsApp sender-authorization path before approval resolution.

Question reactions

For an ask_user prompt with one non-secret, single-select question and one to four options, WhatsApp shows 1️⃣ through 4️⃣ beside the option labels. React to the delivered prompt with the matching number to answer it. Apus maps the number to the canonical option through the Gateway; stale or duplicate taps are ignored. Multi-question, multi-select, and free-text prompts remain text-reply-only. Normal WhatsApp DM/group admission rules authorize the reacting sender.

Plugin hooks and privacy

Inbound WhatsApp messages can carry personal content, phone numbers, group identifiers, sender names, and session correlation fields. WhatsApp does not broadcast inbound message_received hook payloads to plugins unless you opt in:

{
  channels: {
    whatsapp: {
      pluginHooks: {
        messageReceived: true,
      },
    },
  },
}

Scope the opt-in to one account under channels.whatsapp.accounts.<id>.pluginHooks.messageReceived. Only enable this for plugins you trust with inbound WhatsApp content and identifiers.

Access control

Access control and activation

DM policy

`channels.whatsapp.dmPolicy`:

| Value | Behavior |
| --- | --- |
| `pairing` (default) | Unknown senders request pairing; owner approves |
| `allowlist` | Only `allowFrom` senders admitted |
| `open` | Requires `allowFrom` to include `"*"` |
| `disabled` | Block all DMs |

`allowFrom` accepts E.164-style numbers (normalized internally). It is a DM sender access-control list only — it does not gate explicit outbound sends to group JIDs or `@newsletter` channel JIDs.

Multi-account override: `channels.whatsapp.accounts.<id>.dmPolicy` (and `.allowFrom`) take precedence over channel-level defaults for that account.

Runtime notes:

- pairings persist in the channel allow-store and merge with configured `allowFrom`
- scheduled automation and heartbeat recipient fallback use explicit delivery targets or configured `allowFrom`; DM pairing approvals are not implicit cron/heartbeat recipients
- same-number self-DMs are allowed unless `selfChatMode: false` or `dmPolicy: "disabled"`; see [Self-chat behavior](/channels/whatsapp#personal-number-and-self-chat-behavior)
- Apus never auto-pairs outbound `fromMe` DMs (messages you send yourself from the linked device)

Group policy and allowlists

Group access has two layers:

1. **Group membership allowlist** (`channels.whatsapp.groups`): if `groups` is omitted, all groups are eligible; if present, it acts as a group allowlist (`"*"` admits all).
2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`): `open` bypasses the sender allowlist, `allowlist` requires a `groupAllowFrom` (or `*`) match, `disabled` blocks all group inbound.

If `groupAllowFrom` is unset, sender checks fall back to `allowFrom` when it has entries. Sender allowlists are evaluated before mention/reply activation.

If no `channels.whatsapp` block exists at all, runtime falls back to `groupPolicy: "allowlist"` (with a warning log), even if `channels.defaults.groupPolicy` is set to something else.
Inbound group handling uses `channels.whatsapp.accounts.<id>.groups` when set. Named accounts otherwise inherit `channels.whatsapp.accounts.default.groups`, then `channels.whatsapp.groups`; the default account uses its own map or the root map. Group maps replace one another as a whole, rather than merging individual group entries. An explicitly empty map (`{}`) replaces the inherited map too.

Mentions and /activation

Group replies require a mention by default. Mention detection includes:

- explicit WhatsApp mentions of the bot identity
- mention regex patterns (`agents.entries.*.groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`); when neither is set, patterns are derived from the routed agent's `identity.name` and `identity.emoji`
- inbound voice-note transcripts for authorized group messages
- implicit reply-to-bot detection (reply sender matches bot identity)

An explicit `mentionPatterns: []` at the selected agent or global level suppresses identity-derived text patterns. Native mentions and reply-to-bot detection remain separate.

To process all allowed messages in a group, set `groups["<group-id>"].requireMention: false` in the map already supplying the account's group policy: `channels.whatsapp.groups` or the effective `channels.whatsapp.accounts.<id>.groups` map, including inherited `accounts.default.groups`. Preserve every existing wildcard and per-group setting. If you intentionally create an account-specific map instead of editing an inherited map, copy the complete inherited map first, then change only the target group's `requireMention`. When no map previously applied, include `"*": {}` to keep other chats admitted; retain existing restrictions otherwise. A saved session activation mode takes precedence over this config default; use `/activation always` for that session.

Security: quote/reply only satisfies mention gating — it does **not** grant sender authorization. With `groupPolicy: "allowlist"`, non-allowlisted senders stay blocked even replying to an allowlisted user's message.

Session-level activation command: `/activation mention` or `/activation always`. This updates session state (not global config) and is owner-gated.

Named accounts use only their own account-scoped activation. They no longer
inherit an older unscoped group's preference. If that was your only saved
preference, the configured mention policy applies until you run `/activation`
again in the intended account's group. Existing scoped and default-account
preferences, session history, and stored rows remain unchanged; no automatic
migration copies the older setting.

Configured ACP bindings

WhatsApp supports persistent ACP bindings via top-level bindings[]:

{
  bindings: [
    {
      type: "acp",
      agentId: "codex",
      match: {
        channel: "whatsapp",
        accountId: "work",
        peer: { kind: "direct", id: "+15555550123" },
      },
    },
    {
      type: "acp",
      agentId: "codex",
      match: {
        channel: "whatsapp",
        accountId: "work",
        peer: { kind: "group", id: "120363424282127706@g.us" },
      },
    },
  ],
}

Direct chats match E.164 numbers; groups match WhatsApp group JIDs. Group allowlists, sender policy, and mention/activation gating run before Apus ensures the bound ACP session exists. A matched binding owns the route — broadcast groups do not fan that turn out to ordinary WhatsApp sessions.

Personal-number and self-chat behavior

channels.whatsapp.selfChatMode controls same-number DM admission and self-chat safeguards. Set it at the channel level or override it per account with channels.whatsapp.accounts.<id>.selfChatMode.

The implicit self-number allowance applies only to DMs, not group allowlists.

Self-chat safeguards are enabled by true and disabled by false. When the setting is unset, Apus enables them if the linked self number appears in the configured allowFrom. These safeguards skip read receipts, suppress native self-mention triggers, and supply an identity reply prefix when no response prefix is configured.

A liveness check sent to your own number can therefore become agent input with selfChatMode unset or true. Set selfChatMode: false if you want to exclude those self-originated DMs.

Messaging and delivery

Message normalization and context

Inbound envelope and reply context
Incoming messages are wrapped in the shared inbound envelope. A quoted reply appends context in this form:

```text
[Replying to <sender> id:<stanzaId>]
<quoted body or media placeholder>
[/Replying]
```

Reply metadata (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164) is populated when available. If the quoted target is downloadable media, Apus saves it through the normal inbound media store and exposes `MediaPath`/`MediaType` so the agent can inspect it directly instead of seeing only `<media:image>`.
Media placeholders and location/contact extraction
Media-only messages normalize to placeholders: `<media:image>`, `<media:video>`, `<media:audio>`, `<media:document>`, `<media:sticker>`.

Authorized group voice notes are transcribed before mention gating when the body is only `<media:audio>`, so saying the bot mention in the voice note can trigger the reply. If the transcript still does not mention the bot, it stays in pending group history instead of the raw placeholder.

Location bodies render as terse coordinate text. Location labels/comments and contact/vCard details render as fenced untrusted metadata, not inline prompt text.
Pending group history injection
Unprocessed group messages buffer and inject as context when the bot is finally triggered.

- default limit: `50`
- config: `channels.whatsapp.historyLimit`, fallback `messages.groupChat.historyLimit`
- `0` disables

Injection markers: `[Chat messages since your last reply - for context]` and `[Current message - respond to this]`.
Read receipts
Enabled by default for accepted inbound messages. Disable globally:

```json5
{ channels: { whatsapp: { sendReadReceipts: false } } }
```

Per-account override: `channels.whatsapp.accounts.<id>.sendReadReceipts`. Self-chat safeguards skip read receipts even when globally enabled (see [Self-chat behavior](/channels/whatsapp#personal-number-and-self-chat-behavior)).

Delivery, chunking, and media

Text chunking
- default chunk limit: `channels.whatsapp.textChunkLimit = 4000`
- Markdown is converted before chunks are sent, preserving styles across message boundaries and counting WhatsApp formatting markers toward the limit
- `channels.whatsapp.streaming.chunkMode = "length" | "newline"`; `newline` prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
Outbound media behavior
- supports image, video, audio (PTT voice-note), and document payloads
- audio is sent as the Baileys `audio` payload with `ptt: true`, rendering as a push-to-talk voice note; `audioAsVoice` is preserved on reply payloads so TTS voice-note output stays on this path regardless of the provider's source format
- native Ogg/Opus audio sends as `audio/ogg; codecs=opus`; anything else (including Microsoft Edge TTS MP3/WebM output) is transcoded with `ffmpeg` to 48 kHz mono Ogg/Opus before PTT delivery
- `/tts latest` sends the latest assistant reply as one voice note and suppresses repeat sends for the same reply; `/tts chat on|off|default` controls auto-TTS for the current chat
- `gifPlayback: true` on video sends enables animated GIF playback
- `forceDocument`/`asDocument` routes outbound images, GIFs, and videos through the Baileys document payload to avoid WhatsApp's media compression, preserving the resolved filename and MIME type
- captions apply to the first media item in a multi-media reply, except PTT voice notes: the audio sends first with no caption, then the caption sends as a separate text message (WhatsApp clients do not render voice-note captions consistently)
- when a later captioned reply repeats pending attachments, only attachments accepted by WhatsApp replace their pending copies; unmatched attachments remain queued for delivery
- media source can be HTTP(S), `file://`, or a local path
Media size limits and fallback behavior
- inbound save cap and outbound send cap: `channels.whatsapp.mediaMaxMb` (default `50`)
- per-account override: `channels.whatsapp.accounts.<id>.mediaMaxMb`
- images auto-optimize (resize/quality sweep) to fit limits unless `forceDocument`/`asDocument` requests document delivery
- on media send failure, the first-item fallback sends a text warning instead of dropping the response silently

Reply quoting

channels.whatsapp.replyToMode controls native reply quoting (outbound replies visibly quote the inbound message):

Value Behavior
"off" (default) Never quote; send as a plain message
"first" Quote only the first outbound reply chunk
"all" Quote every outbound reply chunk
"batched" Quote queued batched replies; leave immediate replies unquoted

If the original message text and media details are no longer cached, Apus sends the reply as a plain message. The reply body is preserved, but its visual link to the original message is lost.

Per-account override: channels.whatsapp.accounts.<id>.replyToMode.

{ channels: { whatsapp: { replyToMode: "first" } } }

Reactions and typing

Reaction level

channels.whatsapp.reactionLevel controls how broadly the agent uses emoji reactions:

Level Ack reactions Agent-initiated reactions
"off" No No
"ack" Yes No
"minimal" (default) Yes Yes, conservative guidance
"extensive" Yes Yes, encouraged guidance

Per-account override: channels.whatsapp.accounts.<id>.reactionLevel.

{ channels: { whatsapp: { reactionLevel: "ack" } } }

Acknowledgment reactions

messages.ackReaction sends an immediate reaction on inbound receipt, gated by the active WhatsApp account's reactionLevel (suppressed when "off"). messages.ackReactionScope selects direct messages, groups, or both:

{
  messages: {
    ackReaction: "👀",
    ackReactionScope: "group-mentions", // all | direct | group-all | group-mentions | off
  },
}

Notes: the reaction is sent immediately after inbound is accepted (pre-reply); omit messages.ackReaction or set it to "" for no acknowledgment. Failures are logged but do not block reply delivery. The default scope is "group-mentions"; use "all" for direct messages and all eligible groups. In a group whose activation is always, "group-mentions" acks every message rather than only mention-triggered turns, because activation stands in for the mention check.

Lifecycle status reactions

Set messages.statusReactions.enabled: true to let WhatsApp replace the ack reaction during a turn instead of leaving a static receipt emoji, cycling through states such as queued, thinking, tool activity, compaction, done, and error:

{
  messages: {
    statusReactions: {
      enabled: true,
    },
  },
}

Notes: messages.ackReactionScope still controls eligibility for direct messages and groups; the queued state uses the same effective emoji as plain acknowledgment reactions. WhatsApp has one bot reaction slot per message, so lifecycle updates replace the current reaction in place and restore the acknowledgment after the final done/error state.

Active-turn typing

For admitted automatic turns where typing is allowed, WhatsApp sends a composing presence update when agent execution begins and refreshes it while the turn remains active. Refreshing stops when the run completes, including terminal failure or cancellation. The controller seals and cleans up when the reply dispatcher reports idle, or after a short safety timeout if that signal does not arrive. Turns for which the existing typing and suppression policy disables typing do not start this activity.

Typing presence is ephemeral, best-effort activity feedback. It is not a persisted message, delivery receipt, or guarantee that every WhatsApp client will display continuous activity; reconnects and client behavior can make the indicator disappear. Lifecycle status reactions remain the persistent-looking opt-in status surface described above.

Multi-account and credentials

Account selection and defaults
Account ids come from `channels.whatsapp.accounts`. Default account selection is `default` if present, otherwise the first configured account id (alphabetically sorted). Account ids are normalized internally for lookup.

Named accounts resolve shared settings in this order: the account, `accounts.default`, then the channel root. This includes `dmPolicy` and `groupPolicy`: omission inherits, while an explicit value wins. With no policy configured, DMs use `pairing` and groups use `allowlist`. The default account's `authDir`, `enabled`, `name`, and `selfChatMode` are not shared with named accounts.
Credential paths and legacy compatibility
- current auth path: `~/.apus/credentials/whatsapp/<accountId>/creds.json` (backup: `creds.json.bak`)
- Doctor moves implicit legacy default auth from `~/.apus/credentials/` into the account directory after preserving exact private `.migrated` backups. Runtime account selection uses the account directory; run `apus doctor --fix` after replacing an older installation directly.
- Explicit `authDir` settings remain authoritative, including an account that deliberately uses the shared credentials root. Doctor leaves those directories in place. Differing canonical and legacy credentials stay intact with an actionable conflict warning; Doctor never mixes two credential sets.
- Doctor records a credential migration receipt before writing the account directory. If an interrupted import's complete destination is later missing or changed, Doctor preserves the remaining sources and backups and asks for explicit recovery. It does not recreate a logged-out account.
- Canonical account credentials still work on Apus 2026.9.7. Shared-root repair needs a host with offline Doctor migration authority and source backups. If Doctor asks you to upgrade the host, update Apus core before retrying; the plugin leaves the original credentials untouched.
Logout behavior
`apus channels logout --channel whatsapp [--account <id>]` clears WhatsApp auth state for that account. When a gateway is reachable, logout stops the live listener for that account first, so the linked session stops receiving messages before the next restart. `apus channels remove --channel whatsapp` also stops the live listener before disabling or deleting account config.

Logout leaves explicitly configured credential directories outside the managed account tree in place. It never deletes unrelated OAuth credentials from the shared root.

Tools, actions, and config writes

Troubleshooting

Not linked (QR required)
Symptom: channel status reports not linked.
apus channels login --channel whatsapp
apus channels status
Linked but disconnected / reconnect loop
Symptom: linked account with repeated disconnects or reconnect attempts.

Quiet accounts can stay connected past the normal message timeout; the watchdog restarts only when WhatsApp Web transport activity stops, the socket closes, or application-level activity stays silent beyond the longer safety window (see Runtime model above).

Fix:

```bash
apus channels status --probe
apus doctor
apus logs --follow
apus gateway status
```

If the loop persists after host connectivity and timing are fixed, back up the account auth directory and re-link:

```bash
cp -a ~/.apus/credentials/whatsapp/<accountId> \
  ~/.apus/credentials/whatsapp/<accountId>.bak
apus channels logout --channel whatsapp --account <accountId>
apus channels login --channel whatsapp --account <accountId>
```

If `~/.apus/logs/whatsapp-health.log` says `Gateway inactive` but `apus gateway status` and `apus channels status --probe` both show healthy, run `apus doctor`. On Linux, doctor warns about legacy crontab entries invoking the retired `~/.apus/bin/ensure-whatsapp.sh` script; remove those entries with `crontab -e` — cron can lack the systemd user-bus environment and make that old script misreport gateway health.
QR login times out behind a proxy
Symptom: `apus channels login --channel whatsapp` fails before showing a usable QR with `status=408 Request Time-out` or a TLS socket disconnect.

WhatsApp Web login uses the gateway host's standard proxy environment (`HTTPS_PROXY`, `HTTP_PROXY`, lowercase variants, `NO_PROXY`). Verify the gateway process inherits the proxy env and that `NO_PROXY` does not match `mmg.whatsapp.net`.
No active listener when sending
Outbound sends fail fast when no active gateway listener exists for the target account. Confirm the gateway is running and the account is linked.
Reply appears in transcript but not in WhatsApp
Transcript rows record what the agent generated; WhatsApp delivery is checked separately. Apus only treats an auto-reply as sent after Baileys returns an outbound message id for at least one visible text or media send.

Ack reactions are independent pre-reply receipts — a successful reaction does not prove the later text/media reply was accepted. Check gateway logs for `auto-reply delivery failed` or `auto-reply was not accepted by WhatsApp provider`.
Group messages unexpectedly ignored
Check in this order: `groupPolicy`, `groupAllowFrom`/`allowFrom`, `groups` allowlist entries, mention gating (`requireMention` + mention patterns), and duplicate keys in `apus.json` (JSON5 later entries override earlier ones — keep a single `groupPolicy` per scope).

If `channels.whatsapp.groups` is present, WhatsApp can still observe messages from other groups, but Apus drops them before session routing. Add the group JID to `channels.whatsapp.groups`, or add `groups["*"]` to admit all groups while keeping sender authorization under `groupPolicy`/`groupAllowFrom`.
Bun runtime warning
Node remains the primary and recommended Gateway runtime. Bun 1.4+ builds with WAL-reset-safe `node:sqlite` are supported as an explicit opt-in; doctor migrates only unsupported Bun services to Node.

System prompts

WhatsApp supports Telegram-style system prompts for groups and direct chats via the groups and direct maps.

Resolution for group messages: the effective groups map is determined first — if the account defines its own groups key at all, it fully replaces the root groups map (no deep merge). Prompt lookup then runs on that single resulting map:

  1. Group-specific prompt (groups["<groupId>"].systemPrompt): used when the group entry exists and its systemPrompt key is defined. An empty string ("") suppresses the wildcard and applies no prompt.
  2. Group wildcard prompt (groups["*"].systemPrompt): used when the specific group entry is absent, or exists without a systemPrompt key.

Resolution for direct messages follows the identical pattern against the direct map and direct["*"].

dms remains the lightweight per-DM history override bucket (dms.<id>.historyLimit). Prompt overrides live under direct.

This account-replaces-root behavior for prompt resolution is a plain shallow override: any account groups/direct key, including an explicit empty object, replaces the root map. It differs from the group-membership allowlist check described above, which has a single-account safety net for an accidentally empty groups: {}.

Difference from Telegram: Telegram uses the same whole-map account override for groups in multi-account configs, but a single account's empty groups: {} falls back to root groups as a migration safety net. Telegram's direct map also has separate DM-topic semantics. In WhatsApp—or for one account among several Telegram accounts—use an explicit empty groups: {} when that account should not inherit root group defaults.

Important behavior:

Example:

{
  channels: {
    whatsapp: {
      groups: {
        // Use only if all groups should be admitted at the root scope.
        // Applies to all accounts that do not define their own groups map.
        "*": { systemPrompt: "Default prompt for all groups." },
      },
      direct: {
        // Applies to all accounts that do not define their own direct map.
        "*": { systemPrompt: "Default prompt for all direct chats." },
      },
      accounts: {
        work: {
          groups: {
            // This account defines its own groups, so root groups are fully
            // replaced. To keep a wildcard, define "*" explicitly here too.
            "120363406415684625@g.us": {
              requireMention: false,
              systemPrompt: "Focus on project management.",
            },
            // Use only if all groups should be admitted in this account.
            "*": { systemPrompt: "Default prompt for work groups." },
          },
          direct: {
            // This account defines its own direct map, so root direct entries are
            // fully replaced. To keep a wildcard, define "*" explicitly here too.
            "+15551234567": { systemPrompt: "Prompt for a specific work direct chat." },
            "*": { systemPrompt: "Default prompt for work direct chats." },
          },
        },
      },
    },
  },
}

Configuration reference pointers

Primary reference: Configuration reference - WhatsApp

Area Fields
Access dmPolicy, allowFrom, groupPolicy, groupAllowFrom, groups
Delivery textChunkLimit, streaming.chunkMode, mediaMaxMb, sendReadReceipts, reactionLevel
Multi-account accounts.<id>.enabled, accounts.<id>.authDir, and other per-account overrides
Operations configWrites, enabled
Inbound batching messages.inbound.debounceMs, messages.inbound.byChannel.whatsapp
Acknowledgments messages.ackReaction, messages.ackReactionScope
Session behavior session.dmScope, historyLimit, dmHistoryLimit, dms.<id>.historyLimit
Prompts groups.<id>.systemPrompt, groups["*"].systemPrompt, direct.<id>.systemPrompt, direct["*"].systemPrompt

Related