Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agents/openclaw/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ messaging_platforms:
- slack
- wechat
- whatsapp
- zalo

# ── Inference ───────────────────────────────────────────────────
inference:
Expand Down
25 changes: 19 additions & 6 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
# SPDX-License-Identifier: Apache-2.0
title: "Messaging Channels"
sidebar-title: "Set Up Messaging Channels"
description: "Connect Telegram, Discord, Slack, WeChat, or WhatsApp to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging."
description: "Connect Telegram, Discord, Slack, WeChat, WhatsApp, or Zalo to your sandboxed OpenClaw or Hermes agent using OpenShell-managed channel messaging."
description-agent: "Explains how Telegram, Discord, Slack, WeChat, and WhatsApp reach sandboxed OpenClaw and Hermes agents through OpenShell-managed processes and NemoClaw channel commands. Use when setting up messaging channels, chat interfaces, or integrations without relying on nemoclaw tunnel start for bridges."
Comment thread
coderabbitai[bot] marked this conversation as resolved.
keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "openshell channel messaging"]
keywords: ["nemoclaw messaging channels", "nemoclaw telegram", "nemoclaw discord", "nemoclaw slack", "nemoclaw wechat", "nemoclaw whatsapp", "nemoclaw zalo", "openshell channel messaging"]
content:
type: "how_to"
skill:
Expand Down Expand Up @@ -59,6 +59,7 @@ For details, refer to [Commands](../reference/commands).
| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | `SLACK_ALLOWED_USERS` for DM and channel `@mention` user allowlisting, `SLACK_ALLOWED_CHANNELS` for channel ID allowlisting |
| WeChat (experimental) | None. Captured through host-side QR scan during `$$nemoclaw onboard` | `WECHAT_ALLOWED_IDS` for DM allowlisting |
| WhatsApp (experimental) | None. Pair through QR after rebuild | None |
| Zalo (OpenClaw only) | `ZALO_BOT_TOKEN` | `ZALO_ALLOWED_IDS` for DM allowlisting, `ZALO_GROUP_POLICY` for OpenClaw group access |

Telegram uses a bot token from [BotFather](https://t.me/BotFather).
Open Telegram, send `/newbot` to [@BotFather](https://t.me/BotFather), follow the prompts, and copy the token.
Expand Down Expand Up @@ -133,9 +134,18 @@ This is the runtime tradeoff of enabling WhatsApp without a host bridge: a paire
NemoClaw cannot detect cross-sandbox WhatsApp conflicts the way it does for token-based channels.
Pair only one sandbox per WhatsApp account at a time.

Zalo connects OpenClaw sandboxes through the [Zalo Bot Platform](https://bot.zaloplatforms.com) Bot API.
It is available for OpenClaw only; Hermes does not expose a Zalo platform.
Create a bot on the Zalo Bot Platform and copy its token (format `id:secret`) into `ZALO_BOT_TOKEN`.
NemoClaw registers the token as the `<sandbox>-zalo-bridge` OpenShell provider and substitutes the `openshell:resolve:env:ZALO_BOT_TOKEN` placeholder inside the sandbox, so the token never lands in the image.
The bridge runs in long-polling mode (`getUpdates`), so no inbound webhook or public URL is required.
Unknown direct-message senders are paired first: the bot replies with a pairing code, and you approve it from the host with `nemoclaw <sandbox> exec -- openclaw pairing approve zalo <code>` (or `openclaw pairing approve zalo <code>` inside the sandbox).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Set `ZALO_ALLOWED_IDS` to a comma-separated list of numeric Zalo user IDs to allowlist DM access directly; Zalo has no username lookup, so use numeric IDs.
`ZALO_GROUP_POLICY` controls OpenClaw Zalo group access and defaults to `allowlist`; set it to `open` to allow any group member or `disabled` to turn off group access.

## Enable Channels During Onboarding

When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, and WhatsApp.
When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, WhatsApp, and Zalo.
Press a channel number to toggle it on or off, then press **Enter** when done.
If you select no channels, pressing **Enter** skips messaging setup.
If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it.
Expand All @@ -160,6 +170,8 @@ export SLACK_BOT_TOKEN=<your-slack-bot-token>
export SLACK_APP_TOKEN=<your-slack-app-token>
export SLACK_ALLOWED_USERS=<your-slack-member-id>
export SLACK_ALLOWED_CHANNELS=<your-slack-channel-id>
export ZALO_BOT_TOKEN=<your-zalo-bot-token>
export ZALO_ALLOWED_IDS=<your-zalo-user-id>
```

This release does not support non-interactive WeChat configuration because the iLink QR handshake requires a human to scan the QR on a paired phone.
Expand Down Expand Up @@ -190,6 +202,7 @@ $$nemoclaw my-assistant channels add discord
$$nemoclaw my-assistant channels add slack
$$nemoclaw my-assistant channels add wechat
$$nemoclaw my-assistant channels add whatsapp
$$nemoclaw my-assistant channels add zalo
```

`channels add` collects whatever each channel needs.
Expand All @@ -206,7 +219,7 @@ Verify the gateway bridge before relying on the channel.
Restore the preset YAML and re-run `$$nemoclaw <sandbox> channels add <channel>`.
Choose the rebuild so the running sandbox image picks up the new channel.
For Telegram, Discord, and Slack, `channels add` also checks the rebuilt runtime for the selected bridge and reports startup, credential, or missing-plugin warnings before returning.
If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, or `SLACK_ALLOWED_CHANNELS`, export them before the rebuild starts.
If you need optional channel settings such as `TELEGRAM_ALLOWED_IDS`, `TELEGRAM_REQUIRE_MENTION`, `TELEGRAM_GROUP_POLICY`, `DISCORD_SERVER_ID`, `DISCORD_USER_ID`, `DISCORD_REQUIRE_MENTION`, `SLACK_ALLOWED_USERS`, `SLACK_ALLOWED_CHANNELS`, `ZALO_ALLOWED_IDS`, or `ZALO_GROUP_POLICY`, export them before the rebuild starts.
You can omit `TELEGRAM_REQUIRE_MENTION` and `DISCORD_REQUIRE_MENTION` when you want the default mention-only mode.
You can omit `TELEGRAM_GROUP_POLICY` when you want OpenClaw Telegram group access to stay open.
Telegram Bot API `sendMessage` calls prove outbound delivery from the bot; to test inbound agent replies, send a message from the Telegram client as an allowed user.
Expand Down Expand Up @@ -304,7 +317,7 @@ For WeChat specifically, `channels stop wechat` followed by a rebuild keeps the
A subsequent `channels start wechat` plus rebuild revives the bridge against the same iLink account without a fresh QR scan.
The bot token is held by the OpenShell provider across the stop/start cycle.

Telegram, Discord, Slack, and WeChat each allow only one active consumer per channel credential.
Telegram, Discord, Slack, WeChat, and Zalo each allow only one active consumer per channel credential.
Multiple sandboxes can use the same channel type at the same time when each sandbox uses a distinct bot/app token (or a distinct WeChat iLink bot account).
For example, two Telegram sandboxes can DM the same `TELEGRAM_ALLOWED_IDS` account as long as they use different `TELEGRAM_BOT_TOKEN` values.
For WeChat, each sandbox must own a distinct iLink `accountId` (bot identity).
Expand All @@ -330,7 +343,7 @@ Stopping the in-sandbox gateway stops Telegram, Discord, Slack, WeChat, and What

After the sandbox is running, send a message to the configured bot or app.
If delivery fails, use `openshell term` on the host, check gateway logs, and verify network policy allows the channel API.
Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples).
Use the matching policy preset (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`) or review [Common Integration Policy Examples](../network-policy/integration-policy-examples).

## Tunnel Command

Expand Down
3 changes: 2 additions & 1 deletion docs/network-policy/integration-policy-examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ NemoClaw ships maintained policy presets for common services in `nemoclaw-bluepr
| Weather and geocoding APIs | `weather` |
| WeChat (personal) iLink Bot API (experimental) | `wechat` |
| WhatsApp Web messaging (experimental) | `whatsapp` |
| Zalo Bot API (OpenClaw only) | `zalo` |

Preview the endpoints before applying:

Expand Down Expand Up @@ -126,7 +127,7 @@ If delivery fails, open the TUI and send a test message to the bot:
openshell term
```

The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`).
The matching preset for each supported messaging channel is the channel name (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`).

## Slack or Discord Messaging

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/commands-nemohermes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ nemohermes my-assistant hosts-remove searxng.local

### `nemohermes <name> channels list`

List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description.
List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `zalo`) with a short description.
The command is a static reference; it does not consult credentials or the running sandbox.
WeChat and WhatsApp are experimental.

Expand Down Expand Up @@ -899,7 +899,7 @@ If you omit the required `<channel>` argument, the CLI prints the `channels add
Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel.
Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt.
When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway.
If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone.
If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone.
NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild.

For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect:
Expand All @@ -924,7 +924,7 @@ Host-side removal is the supported path because agent channel config is baked in

### `nemohermes <name> channels stop <channel>`

Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials.
Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`) without clearing its credentials.
The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway.
The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens.

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,7 @@ $$nemoclaw my-assistant hosts-remove searxng.local

### `$$nemoclaw <name> channels list`

List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`) with a short description.
List the messaging channels NemoClaw knows about (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, `zalo`) with a short description.
The command is a static reference; it does not consult credentials or the running sandbox.
WeChat and WhatsApp are experimental.

Expand Down Expand Up @@ -1149,7 +1149,7 @@ If you omit the required `<channel>` argument, the CLI prints the `channels add
Clear the stored credentials for a messaging channel and rebuild the sandbox so the image drops the channel.
Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt.
When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway.
If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone.
If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone.
NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild.

For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect:
Expand All @@ -1174,7 +1174,7 @@ Host-side removal is the supported path because agent channel config is baked in

### `$$nemoclaw <name> channels stop <channel>`

Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, or `whatsapp`) without clearing its credentials.
Pause a single messaging bridge (`telegram`, `discord`, `slack`, `wechat`, `whatsapp`, or `zalo`) without clearing its credentials.
The channel is marked disabled in the per-sandbox registry, and the sandbox is rebuilt so the onboard step skips registering the bridge with the gateway.
The provider stays registered with the OpenShell gateway, so a later `channels start` brings the bridge back without re-entering tokens.

Expand Down
23 changes: 23 additions & 0 deletions nemoclaw-blueprint/policies/presets/zalo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

preset:
name: zalo
description: "Zalo Bot API access (long-polling)"

network_policies:
zalo:
name: zalo
endpoints:
# Zalo Bot API. Long-polling (getUpdates) is GET; sending messages is POST.
# The bot.zaloplatforms.com portal is only used to create the bot, not at runtime.
- host: bot-api.zaloplatforms.com
port: 443
protocol: rest
enforcement: enforce
rules:
- allow: { method: GET, path: "/**" }
- allow: { method: POST, path: "/**" }
binaries:
- { path: /usr/local/bin/node }
- { path: /usr/bin/node }
1 change: 1 addition & 0 deletions src/lib/agent/defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("agent definitions", () => {
"slack",
"wechat",
"whatsapp",
"zalo",
]);
expect(openclaw.inferenceProviderOptions).toEqual([]);
// #5027: openclaw.json must be declared as a durable state file so
Expand Down
2 changes: 2 additions & 0 deletions src/lib/messaging-channel-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ describe("messaging channel config", () => {
"SLACK_ALLOWED_USERS",
"SLACK_ALLOWED_CHANNELS",
"WHATSAPP_ALLOWED_IDS",
"ZALO_ALLOWED_IDS",
"TELEGRAM_GROUP_POLICY",
"WECHAT_ACCOUNT_ID",
"WECHAT_BASE_URL",
"WECHAT_USER_ID",
"ZALO_GROUP_POLICY",
]);
});

Expand Down
3 changes: 3 additions & 0 deletions src/lib/messaging/channels/built-ins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,22 @@ import { slackManifest } from "./slack/manifest";
import { telegramManifest } from "./telegram/manifest";
import { wechatManifest } from "./wechat/manifest";
import { whatsappManifest } from "./whatsapp/manifest";
import { zaloManifest } from "./zalo/manifest";

export { discordManifest } from "./discord/manifest";
export { slackManifest } from "./slack/manifest";
export { telegramManifest } from "./telegram/manifest";
export { wechatManifest } from "./wechat/manifest";
export { whatsappManifest } from "./whatsapp/manifest";
export { zaloManifest } from "./zalo/manifest";

export const BUILT_IN_CHANNEL_MANIFESTS = [
telegramManifest,
discordManifest,
wechatManifest,
slackManifest,
whatsappManifest,
zaloManifest,
] as const;

export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry {
Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/manifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ describe("built-in channel manifests", () => {
"wechat",
"slack",
"whatsapp",
"zalo",
]);
expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual([
"telegram",
Expand Down
4 changes: 4 additions & 0 deletions src/lib/messaging/channels/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("built-in messaging channel metadata", () => {
"wechat",
"slack",
"whatsapp",
"zalo",
]);
expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([
"telegram",
Expand Down Expand Up @@ -78,6 +79,8 @@ describe("built-in messaging channel metadata", () => {
"SLACK_ALLOWED_USERS",
"SLACK_ALLOWED_CHANNELS",
"WHATSAPP_ALLOWED_IDS",
"ZALO_ALLOWED_IDS",
"ZALO_GROUP_POLICY",
]);
expect(getMessagingConfigEnvAliases()).toEqual({
DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"],
Expand Down Expand Up @@ -110,6 +113,7 @@ describe("built-in messaging channel metadata", () => {
"openclaw-weixin",
"slack",
"whatsapp",
"zalo",
]);
expect(
Object.fromEntries(
Expand Down
2 changes: 1 addition & 1 deletion src/lib/messaging/channels/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ export function listMessagingPackageInstallSpecs(
}

function selectManifests(options: MessagingManifestMetadataOptions): ChannelManifest[] {
const manifests = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS;
const manifests: readonly ChannelManifest[] = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS;
const agent = options.agent;
const selected = agent
? manifests.filter((manifest) => manifest.supportedAgents.includes(agent))
Expand Down
2 changes: 2 additions & 0 deletions src/lib/messaging/channels/template-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import { resolveTelegramTemplateReference } from "./telegram/template-resolver";
import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils";
import { resolveWechatTemplateReference } from "./wechat/template-resolver";
import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver";
import { resolveZaloTemplateReference } from "./zalo/template-resolver";

const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResolver[] = [
resolveTelegramTemplateReference,
resolveDiscordTemplateReference,
resolveWechatTemplateReference,
resolveSlackTemplateReference,
resolveWhatsappTemplateReference,
resolveZaloTemplateReference,
];

export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/messaging/channels/zalo/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { MessagingHookRegistration } from "../../../hooks/types";
import type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health";
import { createZaloOpenClawBridgeHealthHookRegistration } from "./openclaw-bridge-health";

export * from "./openclaw-bridge-health";

export interface ZaloHookOptions {
readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions;
}

export function createZaloHookRegistrations(
options: ZaloHookOptions = {},
): readonly MessagingHookRegistration[] {
return [createZaloOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth)] as const;
}
23 changes: 23 additions & 0 deletions src/lib/messaging/channels/zalo/hooks/openclaw-bridge-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
createOpenClawBridgeHealthHookRegistration,
type OpenClawBridgeHealthHookOptions,
} from "../../openclaw-bridge-health";

export type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health";

export const ZALO_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID = "zalo.openclawBridgeHealth";

export function createZaloOpenClawBridgeHealthHookRegistration(
options: OpenClawBridgeHealthHookOptions = {},
) {
return createOpenClawBridgeHealthHookRegistration(
{
channelId: "zalo",
handlerId: ZALO_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID,
},
options,
);
}
Loading
Loading