diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ec0070906..5f5332b5a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
+- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
### Fixes
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index 8b96ad17dd..4942797231 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -313,6 +313,23 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
+
+ `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+ Resolution order:
+
+ - `channels.discord.accounts..ackReaction`
+ - `channels.discord.ackReaction`
+ - `messages.ackReaction`
+ - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+ Notes:
+
+ - Discord accepts unicode emoji or custom emoji names.
+ - Use `""` to disable the reaction for a channel or account.
+
+
+
Channel-initiated config writes are enabled by default.
diff --git a/docs/channels/slack.md b/docs/channels/slack.md
index 243e2f6d04..c4e95c21cf 100644
--- a/docs/channels/slack.md
+++ b/docs/channels/slack.md
@@ -287,6 +287,22 @@ Available action groups in current Slack tooling:
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
+## Ack reactions
+
+`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+Resolution order:
+
+- `channels.slack.accounts..ackReaction`
+- `channels.slack.ackReaction`
+- `messages.ackReaction`
+- agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+Notes:
+
+- Slack expects shortcodes (for example `"eyes"`).
+- Use `""` to disable the reaction for a channel or account.
+
## Manifest and scope checklist
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index a919d20b0c..28a9c227f9 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -571,6 +571,23 @@ curl "https://api.telegram.org/bot/getUpdates"
+
+ `ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message.
+
+ Resolution order:
+
+ - `channels.telegram.accounts..ackReaction`
+ - `channels.telegram.ackReaction`
+ - `messages.ackReaction`
+ - agent identity emoji fallback (`agents.list[].identity.emoji`, else "👀")
+
+ Notes:
+
+ - Telegram expects unicode emoji (for example "👀").
+ - Use `""` to disable the reaction for a channel or account.
+
+
+
Channel config writes are enabled by default (`configWrites !== false`).
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index 4a02e4ff44..eeb1eaea7b 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -1238,6 +1238,8 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
### Ack reaction
- Defaults to active agent's `identity.emoji`, otherwise `"👀"`. Set `""` to disable.
+- Per-channel overrides: `channels..ackReaction`, `channels..accounts..ackReaction`.
+- Resolution order: account → channel → `messages.ackReaction` → identity fallback.
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
- `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only).
diff --git a/src/agents/identity.test.ts b/src/agents/identity.test.ts
new file mode 100644
index 0000000000..7ff865fe14
--- /dev/null
+++ b/src/agents/identity.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import type { OpenClawConfig } from "../config/config.js";
+import { resolveAckReaction } from "./identity.js";
+
+describe("resolveAckReaction", () => {
+ it("prefers account-level overrides", () => {
+ const cfg: OpenClawConfig = {
+ messages: { ackReaction: "👀" },
+ agents: { list: [{ id: "main", identity: { emoji: "✅" } }] },
+ channels: {
+ slack: {
+ ackReaction: "eyes",
+ accounts: {
+ acct1: { ackReaction: " party_parrot " },
+ },
+ },
+ },
+ };
+
+ expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "acct1" })).toBe(
+ "party_parrot",
+ );
+ });
+
+ it("falls back to channel-level overrides", () => {
+ const cfg: OpenClawConfig = {
+ messages: { ackReaction: "👀" },
+ agents: { list: [{ id: "main", identity: { emoji: "✅" } }] },
+ channels: {
+ slack: {
+ ackReaction: "eyes",
+ accounts: {
+ acct1: { ackReaction: "party_parrot" },
+ },
+ },
+ },
+ };
+
+ expect(resolveAckReaction(cfg, "main", { channel: "slack", accountId: "missing" })).toBe(
+ "eyes",
+ );
+ });
+
+ it("uses the global ackReaction when channel overrides are missing", () => {
+ const cfg: OpenClawConfig = {
+ messages: { ackReaction: "✅" },
+ agents: { list: [{ id: "main", identity: { emoji: "😺" } }] },
+ };
+
+ expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("✅");
+ });
+
+ it("falls back to the agent identity emoji when global config is unset", () => {
+ const cfg: OpenClawConfig = {
+ agents: { list: [{ id: "main", identity: { emoji: "🔥" } }] },
+ };
+
+ expect(resolveAckReaction(cfg, "main", { channel: "discord" })).toBe("🔥");
+ });
+
+ it("returns the default emoji when no config is present", () => {
+ const cfg: OpenClawConfig = {};
+
+ expect(resolveAckReaction(cfg, "main")).toBe("👀");
+ });
+
+ it("allows empty strings to disable reactions", () => {
+ const cfg: OpenClawConfig = {
+ messages: { ackReaction: "👀" },
+ channels: {
+ telegram: {
+ ackReaction: "",
+ },
+ },
+ };
+
+ expect(resolveAckReaction(cfg, "main", { channel: "telegram" })).toBe("");
+ });
+});
diff --git a/src/agents/identity.ts b/src/agents/identity.ts
index 1ce3831ad9..ae27c88149 100644
--- a/src/agents/identity.ts
+++ b/src/agents/identity.ts
@@ -10,11 +10,37 @@ export function resolveAgentIdentity(
return resolveAgentConfig(cfg, agentId)?.identity;
}
-export function resolveAckReaction(cfg: OpenClawConfig, agentId: string): string {
+export function resolveAckReaction(
+ cfg: OpenClawConfig,
+ agentId: string,
+ opts?: { channel?: string; accountId?: string },
+): string {
+ // L1: Channel account level
+ if (opts?.channel && opts?.accountId) {
+ const channelCfg = getChannelConfig(cfg, opts.channel);
+ const accounts = channelCfg?.accounts as Record> | undefined;
+ const accountReaction = accounts?.[opts.accountId]?.ackReaction as string | undefined;
+ if (accountReaction !== undefined) {
+ return accountReaction.trim();
+ }
+ }
+
+ // L2: Channel level
+ if (opts?.channel) {
+ const channelCfg = getChannelConfig(cfg, opts.channel);
+ const channelReaction = channelCfg?.ackReaction as string | undefined;
+ if (channelReaction !== undefined) {
+ return channelReaction.trim();
+ }
+ }
+
+ // L3: Global messages level
const configured = cfg.messages?.ackReaction;
if (configured !== undefined) {
return configured.trim();
}
+
+ // L4: Agent identity emoji fallback
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || DEFAULT_ACK_REACTION;
}
diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts
index c9ba2ed6f3..b2e1652907 100644
--- a/src/config/types.discord.ts
+++ b/src/config/types.discord.ts
@@ -200,6 +200,11 @@ export type DiscordAccountConfig = {
pluralkit?: DiscordPluralKitConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
+ /**
+ * Per-channel ack reaction override.
+ * Discord supports both unicode emoji and custom emoji names.
+ */
+ ackReaction?: string;
/** Bot activity status text (e.g. "Watching X"). */
activity?: string;
/** Bot status (online|dnd|idle|invisible). Defaults to online when presence is configured. */
diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts
index a7f7bef2c8..ead656cce2 100644
--- a/src/config/types.slack.ts
+++ b/src/config/types.slack.ts
@@ -156,6 +156,11 @@ export type SlackAccountConfig = {
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
+ /**
+ * Per-channel ack reaction override.
+ * Slack uses shortcodes (e.g., "eyes") rather than unicode emoji.
+ */
+ ackReaction?: string;
};
export type SlackConfig = {
diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts
index 99b28cf793..d8e189e756 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -141,6 +141,11 @@ export type TelegramAccountConfig = {
* Use `"auto"` to derive `[{identity.name}]` from the routed agent.
*/
responsePrefix?: string;
+ /**
+ * Per-channel ack reaction override.
+ * Telegram expects unicode emoji (e.g., "👀") rather than shortcodes.
+ */
+ ackReaction?: string;
};
export type TelegramTopicConfig = {
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index eaca32ffa5..ed40d5e62b 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -143,6 +143,7 @@ export const TelegramAccountSchemaBase = z
heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
responsePrefix: z.string().optional(),
+ ackReaction: z.string().optional(),
})
.strict();
@@ -341,6 +342,7 @@ export const DiscordAccountSchema = z
.strict()
.optional(),
responsePrefix: z.string().optional(),
+ ackReaction: z.string().optional(),
activity: z.string().optional(),
status: z.enum(["online", "dnd", "idle", "invisible"]).optional(),
activityType: z
@@ -572,6 +574,7 @@ export const SlackAccountSchema = z
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
responsePrefix: z.string().optional(),
+ ackReaction: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts
index e0d849d40e..04e45af24f 100644
--- a/src/discord/monitor/message-handler.process.ts
+++ b/src/discord/monitor/message-handler.process.ts
@@ -88,7 +88,10 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
}
- const ackReaction = resolveAckReaction(cfg, route.agentId);
+ const ackReaction = resolveAckReaction(cfg, route.agentId, {
+ channel: "discord",
+ accountId,
+ });
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () =>
Boolean(
diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts
index 72b7d9271f..d7296646d5 100644
--- a/src/slack/monitor/message-handler/prepare.ts
+++ b/src/slack/monitor/message-handler/prepare.ts
@@ -348,7 +348,10 @@ export async function prepareSlackMessage(params: {
return null;
}
- const ackReaction = resolveAckReaction(cfg, route.agentId);
+ const ackReaction = resolveAckReaction(cfg, route.agentId, {
+ channel: "slack",
+ accountId: account.accountId,
+ });
const ackReactionValue = ackReaction ?? "";
const shouldAckReaction = () =>
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index 9cd8f91106..9723419f4d 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -502,7 +502,10 @@ export const buildTelegramMessageContext = async ({
}
// ACK reactions
- const ackReaction = resolveAckReaction(cfg, route.agentId);
+ const ackReaction = resolveAckReaction(cfg, route.agentId, {
+ channel: "telegram",
+ accountId: account.accountId,
+ });
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () =>
Boolean(