Zalo Personal Channel Setup with zca-js QR Login

Covers the experimental Zalo personal account integration through native zca-js with QR login, its capabilities, and configuration. Aimed at operators setting up the external plugin and approving DM pairing.

Read this when

  • Setting up Zalo Personal for OpenClaw
  • Debugging Zalo Personal login or message flow

Status: experimental. This integration drives a personal Zalo account through native zca-js inside the process, so no external CLI binary is needed.

Warning

The integration is unofficial and could get your account suspended or banned. You accept that risk by using it.

Install

Zalo Personal ships as an official external plugin and is not part of core. Install it first:

openclaw plugins install @openclaw/zalouser
  • To pin a version: openclaw plugins install @openclaw/zalouser@<version>
  • To install from a source checkout: openclaw plugins install ./path/to/local/zalouser-plugin
  • More information: Plugins

Quick setup

  1. Install the plugin (see above).
  2. Log in with a QR code on the Gateway machine:
    • openclaw channels login --channel zalouser
    • Use the Zalo mobile app to scan the QR code.
  3. Turn on the channel:
{
  channels: {
    zalouser: {
      enabled: true,
      dmPolicy: "pairing",
    },
  },
}
  1. Inspect openclaw channels status --probe and start the Gateway when it is offline. Configuration edits are picked up per hot reload.
  2. Direct messages are gated by pairing by default; on first contact, approve the pairing code.

What it is

  • Everything runs in-process through the zca-js library (no external zca/openzca binary is involved).
  • Inbound messages arrive through native event listeners (message, error).
  • Replies go out straight from the JS API (text, media, or link).
  • Built for "personal account" scenarios in which the Zalo Bot API cannot be used.

Naming

The channel id zalouser makes clear that this automates an unofficial personal Zalo user account. A possible future official Zalo API integration is what zalo is held for.

Finding IDs (directory)

openclaw directory self --channel zalouser
openclaw directory peers list --channel zalouser --query "name"
openclaw directory groups list --channel zalouser --query "work"

Limits

  • Outbound text is split into chunks of 2000 characters, which is the Zalo client limit.
  • If a send is canceled or replaced, its later requests stop once preparation is done. Messages already submitted may still be delivered, and message IDs reported earlier stay on record if a later chunk or audio step fails.
  • channels.zalouser.mediaMaxMb caps each outbound attachment in MiB. An mediaMaxMb set on the selected channel account takes precedence over the root value, and agents.defaults.mediaMaxMb provides the fallback after that. Images may be optimized; when a limit is omitted, the shared loader defaults apply.
  • Streaming has no support.
  • Inbound message ids that have completed are kept for 30 days, capped at the 1000 most recent entries per account.

The optional zalouser tool picks a credential profile rather than a channel account. Its image action applies the current delivery account's cap only if that account uses the chosen profile. In every other case it relies on the channel root and the agent fallback, and it never searches other accounts that happen to share the profile. Profile selection, along with the tool's literal default profile, stays as it is.

Inbound durability

Before processing, OpenClaw persists every raw zca-js message callback. After a Gateway restart, pending messages continue from the account queue, and work remains serialized per direct chat or group.

No delivery acknowledgement is exposed by the zca-js socket listener, and it does not replay old messages automatically on reconnect. So the durable queue covers only the local crash window once a callback has reached OpenClaw; a message the socket never delivered cannot be recovered. Replay tombstones chiefly guard against a repeated callback carrying the same Zalo message id.

Access control (DMs)

channels.zalouser.dmPolicy: pairing | allowlist | open | disabled (default: pairing).

channels.zalouser.allowFrom ought to contain stable Zalo user IDs. Static sender access groups (accessGroup:<name>) may be referenced as well. When setup runs interactively, names you enter can be resolved to IDs through the plugin's in-process contact lookup.

Should a raw name still sit in config, startup resolves it only if channels.zalouser.dangerouslyAllowNameMatching: true is enabled. Absent that opt-in, runtime sender checks work on IDs alone, and raw names carry no weight for authorization.

To approve:

  • openclaw pairing list zalouser
  • openclaw pairing approve zalouser <code>

Group access (optional)

  • Default: channels.zalouser.groupPolicy = "allowlist" (a group needs an explicit allowlist entry).
  • To open every group: channels.zalouser.groupPolicy = "open".
  • To block every group: channels.zalouser.groupPolicy = "disabled".
  • When groupPolicy = "allowlist" is set:
    • channels.zalouser.groups keys should be stable group IDs; names become IDs at startup only if channels.zalouser.dangerouslyAllowNameMatching: true is enabled.
    • Which senders in allowed groups may trigger the bot is governed by channels.zalouser.groupAllowFrom; static sender access groups can be referenced via accessGroup:<name>.
  • The configure wizard is able to prompt for group allowlists.
  • Group allowlist matching is ID-only unless changed. Names that stay unresolved are ignored for auth unless channels.zalouser.dangerouslyAllowNameMatching: true is enabled.
  • channels.zalouser.dangerouslyAllowNameMatching: true is a break-glass compatibility mode: it turns mutable startup name resolution and runtime group-name matching back on.
  • For normal group messages, groupAllowFrom does not fall back to allowFrom: if you leave it empty on an allowlisted group, any sender can reach that group. Authorized control commands (/new, for instance) are the exception; when groupAllowFrom is empty, command sender checks fall back to allowFrom.

Example:

{
  channels: {
    zalouser: {
      groupPolicy: "allowlist",
      groupAllowFrom: ["1471383327500481391"],
      groups: {
        "123456789": { enabled: true },
        "Work Chat": { enabled: true },
      },
    },
  },
}

Note

channels.zalouser.groups.<id>.allow is an older field name; the current config uses enabled. openclaw doctor --fix converts allow to enabled on its own.

Group mention gating

  • Whether group replies need a mention is governed by channels.zalouser.groups.<group>.requireMention.
  • Lookup order: group id -> group:<id> alias -> group name/slug (name-based candidates apply only when dangerouslyAllowNameMatching: true) -> * -> default (true).
  • Works for allowlisted groups and for open group mode alike.
  • An implicit mention for group activation is counted when someone quotes a bot message.
  • Authorized control commands (/new, for instance) may skip mention gating.
  • If a group message is skipped for lacking a required mention, OpenClaw saves it as pending group history and adds it to the next group message it processes.
  • Group history limit: channels.zalouser.historyLimit, then messages.groupChat.historyLimit, with a fallback of 50.

Example:

{
  channels: {
    zalouser: {
      groupPolicy: "allowlist",
      groups: {
        "*": { enabled: true, requireMention: true },
        "Work Chat": { enabled: true, requireMention: false },
      },
    },
  },
}

Multi-account

In OpenClaw state, accounts correspond to zalouser profiles. Example:

{
  channels: {
    zalouser: {
      enabled: true,
      groupPolicy: "allowlist",
      defaultAccount: "work",
      accounts: {
        work: { enabled: true, profile: "work", groupPolicy: "allowlist" },
      },
    },
  },
}

Environment variables

Environment variables can also supply the profile selection:

VarPurpose
ZALOUSER_PROFILEProfile name used when channel or account config sets no profile.
ZCA_PROFILELegacy fallback, consulted only if ZALOUSER_PROFILE is unset.

Saved Zalo login credentials in OpenClaw state are chosen by profile name. Resolution order:

  1. An explicit profile in config.
  2. ZALOUSER_PROFILE.
  3. ZCA_PROFILE.
  4. The account id for non-default accounts, or default for the default account.

With multiple accounts, set profile on each account in config where possible, so a single environment variable does not cause several accounts to share one login session.

Typing, reactions, and delivery acknowledgements

  • Before a reply is dispatched, OpenClaw sends a typing event (best-effort).
  • The message reaction action react is supported for zalouser in channel actions.
    • A specific reaction emoji can be removed from a message with remove: true.
    • Reaction semantics: Reactions
  • For inbound messages carrying event metadata, OpenClaw sends delivered + seen acknowledgements (best-effort).

Troubleshooting

Login doesn't stick:

  • openclaw channels status --probe
  • To log in again: openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser

Allowlist/group name didn't resolve:

  • Put numeric IDs in allowFrom/groupAllowFrom and stable group IDs in groups. If exact friend/group names are genuinely needed, turn on channels.zalouser.dangerouslyAllowNameMatching: true.

Upgraded from an old external zca/CLI-based setup:

  • Drop any assumptions about an external zca process; the channel now runs entirely in-process through zca-js, with no external CLI binary.

DM history is missing after an upgrade:

  • Run openclaw doctor --fix to repair supported legacy DM session keys. Doctor inspects every configured agent, named agents included, and leaves genuine group sessions intact.
1,436 words · updated Oct 25, 2026