diff --git a/CHANGELOG.md b/CHANGELOG.md
index a227a36063c..0782708a9b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo.
- Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive.
- Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos.
+- Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93.
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
- Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu.
diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md
index 4b155360103..fa5474ac7fd 100644
--- a/docs/plugins/sdk-overview.md
+++ b/docs/plugins/sdk-overview.md
@@ -131,18 +131,18 @@ generic contracts; Plan Mode can use them, but so can approval workflows,
workspace policy gates, background monitors, setup wizards, and UI companion
plugins.
-| Method | Contract it owns |
-| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
-| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
-| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
-| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
-| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
-| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` |
-| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
-| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
-| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
-| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
-| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup |
+| Method | Contract it owns |
+| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
+| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions |
+| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session |
+| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params |
+| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
+| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` |
+| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
+| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
+| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
+| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
+| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup |
The contracts intentionally split authority:
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index c8df00b788d..8a4492ede86 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -67,6 +67,7 @@ There are two related systems:
Registers native commands. Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically.
+On Discord, native command specs may include `descriptionLocalizations`, which OpenClaw publishes as Discord `description_localizations` and includes in reconcile comparisons.
Registers **skill** commands natively when supported. Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill). Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`).
@@ -237,6 +238,7 @@ User-invocable skills are also exposed as slash commands:
- `/skill [input]` always works as the generic entrypoint.
- skills may also appear as direct commands like `/prose` when the skill/plugin registers them.
- native skill-command registration is controlled by `commands.nativeSkills` and `channels..commands.nativeSkills`.
+- command specs can provide `descriptionLocalizations` for native surfaces that support localized descriptions, including Discord.
diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts
index 466a821d16c..72541366aeb 100644
--- a/extensions/discord/src/monitor/native-command.options.test.ts
+++ b/extensions/discord/src/monitor/native-command.options.test.ts
@@ -333,4 +333,37 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(requireOption(command, "input").description).toHaveLength(100);
expect(requireOption(command, "input").description).toBe("x".repeat(100));
});
+
+ it("serializes localized command descriptions", () => {
+ const longDescription = "k".repeat(140);
+ const command = createDiscordNativeCommand({
+ command: {
+ name: "localized",
+ description: "Default description",
+ descriptionLocalizations: {
+ ko: "현지화된 설명",
+ "en-GB": longDescription,
+ },
+ acceptsArgs: false,
+ },
+ cfg: {} as OpenClawConfig,
+ discordConfig: {},
+ accountId: "default",
+ sessionPrefix: "discord:slash",
+ ephemeralDefault: true,
+ threadBindings: createNoopThreadBindingManager("default"),
+ });
+
+ expect(command.descriptionLocalizations).toEqual({
+ ko: "현지화된 설명",
+ "en-GB": "k".repeat(100),
+ });
+ expect(command.serialize()).toMatchObject({
+ description: "Default description",
+ description_localizations: {
+ ko: "현지화된 설명",
+ "en-GB": "k".repeat(100),
+ },
+ });
+ });
});
diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts
index c51900aebd5..893be1b472a 100644
--- a/extensions/discord/src/monitor/native-command.options.ts
+++ b/extensions/discord/src/monitor/native-command.options.ts
@@ -28,6 +28,25 @@ export function truncateDiscordCommandDescription(params: {
return value.slice(0, DISCORD_COMMAND_DESCRIPTION_MAX);
}
+export function truncateDiscordCommandDescriptionLocalizations(params: {
+ value?: Record;
+ label: string;
+}): Record | undefined {
+ const entries = Object.entries(params.value ?? {});
+ if (entries.length === 0) {
+ return undefined;
+ }
+ return Object.fromEntries(
+ entries.map(([locale, description]) => [
+ locale,
+ truncateDiscordCommandDescription({
+ value: description,
+ label: `${params.label} locale:${locale}`,
+ }),
+ ]),
+ );
+}
+
function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string {
if (typeof command.nativeName === "string" && command.nativeName.trim().length > 0) {
return command.nativeName;
diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts
index b1591cabd64..f759f5b2213 100644
--- a/extensions/discord/src/monitor/native-command.ts
+++ b/extensions/discord/src/monitor/native-command.ts
@@ -72,6 +72,7 @@ import {
import { createNativeCommandDefinition, readDiscordCommandArgs } from "./native-command.args.js";
import {
buildDiscordCommandOptions,
+ truncateDiscordCommandDescriptionLocalizations,
truncateDiscordCommandDescription,
} from "./native-command.options.js";
import { nativeCommandRuntime } from "./native-command.runtime.js";
@@ -146,6 +147,10 @@ export function createDiscordNativeCommand(params: {
value: command.description,
label: `command:${command.name}`,
});
+ descriptionLocalizations = truncateDiscordCommandDescriptionLocalizations({
+ value: command.descriptionLocalizations,
+ label: `command:${command.name}`,
+ });
defer = false;
ephemeral = ephemeralDefault;
options = options;
diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts
index e7013166eb6..dc4d5eb6f02 100644
--- a/src/agents/skills/types.ts
+++ b/src/agents/skills/types.ts
@@ -52,6 +52,8 @@ export type SkillCommandSpec = {
name: string;
skillName: string;
description: string;
+ /** Localized descriptions for native command surfaces that support them. */
+ descriptionLocalizations?: Record;
/** Optional deterministic dispatch behavior for this command. */
dispatch?: SkillCommandDispatchSpec;
/** Native prompt template used by Claude-bundle command markdown files. */
diff --git a/src/auto-reply/commands-registry-list.ts b/src/auto-reply/commands-registry-list.ts
index bad3bcce332..53dca563f40 100644
--- a/src/auto-reply/commands-registry-list.ts
+++ b/src/auto-reply/commands-registry-list.ts
@@ -8,16 +8,22 @@ function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatC
if (!skillCommands || skillCommands.length === 0) {
return [];
}
- return skillCommands.map((spec) => ({
- key: `skill:${spec.skillName}`,
- nativeName: spec.name,
- description: spec.description,
- textAliases: [`/${spec.name}`],
- acceptsArgs: true,
- argsParsing: "none",
- scope: "both",
- category: "tools",
- }));
+ return skillCommands.map((spec) => {
+ const command: ChatCommandDefinition = {
+ key: `skill:${spec.skillName}`,
+ nativeName: spec.name,
+ description: spec.description,
+ textAliases: [`/${spec.name}`],
+ acceptsArgs: true,
+ argsParsing: "none",
+ scope: "both",
+ category: "tools",
+ };
+ if (spec.descriptionLocalizations) {
+ command.descriptionLocalizations = spec.descriptionLocalizations;
+ }
+ return command;
+ });
}
export function listChatCommands(params?: {
diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts
index e66496f2244..a9802b93910 100644
--- a/src/auto-reply/commands-registry.test.ts
+++ b/src/auto-reply/commands-registry.test.ts
@@ -154,6 +154,7 @@ describe("commands registry", () => {
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
+ descriptionLocalizations: { ko: "데모 스킬" },
},
];
const commands = listChatCommandsForConfig(
@@ -171,7 +172,9 @@ describe("commands registry", () => {
{ commands: { config: false, plugins: false, debug: false, native: true } },
{ skillCommands },
);
- expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
+ expect(native.find((spec) => spec.name === "demo_skill")).toMatchObject({
+ descriptionLocalizations: { ko: "데모 스킬" },
+ });
});
it("applies discord native command overrides", () => {
diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts
index 314a31ffa67..1dd6609de32 100644
--- a/src/auto-reply/commands-registry.ts
+++ b/src/auto-reply/commands-registry.ts
@@ -84,12 +84,16 @@ function resolveNativeName(
}
function toNativeCommandSpec(command: ChatCommandDefinition, provider?: string): NativeCommandSpec {
- return {
+ const spec: NativeCommandSpec = {
name: resolveNativeName(command, provider) ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
};
+ if (command.descriptionLocalizations) {
+ spec.descriptionLocalizations = command.descriptionLocalizations;
+ }
+ return spec;
}
function listNativeSpecsFromCommands(
diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts
index 46f63b76763..ec8c44f6608 100644
--- a/src/auto-reply/commands-registry.types.ts
+++ b/src/auto-reply/commands-registry.types.ts
@@ -59,6 +59,8 @@ export type ChatCommandDefinition = {
key: string;
nativeName?: string;
description: string;
+ /** Localized descriptions for native command surfaces that support them. */
+ descriptionLocalizations?: Record;
textAliases: string[];
acceptsArgs?: boolean;
args?: CommandArgDefinition[];
@@ -74,6 +76,7 @@ export type ChatCommandDefinition = {
export type NativeCommandSpec = {
name: string;
description: string;
+ descriptionLocalizations?: Record;
acceptsArgs: boolean;
args?: CommandArgDefinition[];
};
diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts
index 9231a6791f6..c186148e929 100644
--- a/src/plugins/command-registration.ts
+++ b/src/plugins/command-registration.ts
@@ -169,6 +169,14 @@ export function validatePluginCommandDefinition(
return `Native progress message "${label}" cannot be empty`;
}
}
+ for (const [locale, description] of Object.entries(command.descriptionLocalizations ?? {})) {
+ if (typeof description !== "string") {
+ return `Description localization "${locale}" must be a string`;
+ }
+ if (!description.trim()) {
+ return `Description localization "${locale}" cannot be empty`;
+ }
+ }
return null;
}
diff --git a/src/plugins/command-specs.ts b/src/plugins/command-specs.ts
index e8f72963fda..b8fbf1099be 100644
--- a/src/plugins/command-specs.ts
+++ b/src/plugins/command-specs.ts
@@ -32,6 +32,7 @@ export function getPluginCommandSpecs(
): Array<{
name: string;
description: string;
+ descriptionLocalizations?: Record;
acceptsArgs: boolean;
}> {
const providerName = normalizeOptionalLowercaseString(provider);
@@ -56,11 +57,23 @@ export function getPluginCommandSpecs(
export function listProviderPluginCommandSpecs(provider?: string): Array<{
name: string;
description: string;
+ descriptionLocalizations?: Record;
acceptsArgs: boolean;
}> {
- return Array.from(pluginCommands.values()).map((cmd) => ({
- name: resolvePluginNativeName(cmd, provider),
- description: cmd.description,
- acceptsArgs: cmd.acceptsArgs ?? false,
- }));
+ return Array.from(pluginCommands.values()).map((cmd) => {
+ const spec: {
+ name: string;
+ description: string;
+ descriptionLocalizations?: Record;
+ acceptsArgs: boolean;
+ } = {
+ name: resolvePluginNativeName(cmd, provider),
+ description: cmd.description,
+ acceptsArgs: cmd.acceptsArgs ?? false,
+ };
+ if (cmd.descriptionLocalizations) {
+ spec.descriptionLocalizations = cmd.descriptionLocalizations;
+ }
+ return spec;
+ });
}
diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts
index 136d900f380..9ed993ee4b9 100644
--- a/src/plugins/commands.test.ts
+++ b/src/plugins/commands.test.ts
@@ -454,6 +454,35 @@ describe("registerPluginCommand", () => {
});
});
+ it("exposes native description localizations on plugin command specs", () => {
+ const result = registerVoiceCommandForTest({
+ description: "Demo command",
+ descriptionLocalizations: { ko: "데모 명령" },
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(listProviderPluginCommandSpecs("discord")).toEqual([
+ {
+ name: "voice",
+ description: "Demo command",
+ descriptionLocalizations: { ko: "데모 명령" },
+ acceptsArgs: false,
+ },
+ ]);
+ });
+
+ it("rejects empty native description localizations", () => {
+ const result = registerVoiceCommandForTest({
+ description: "Demo command",
+ descriptionLocalizations: { ko: " " },
+ });
+
+ expect(result).toEqual({
+ ok: false,
+ error: 'Description localization "ko" cannot be empty',
+ });
+ });
+
it("rejects empty native progress metadata", () => {
const result = registerVoiceCommandForTest({
nativeProgressMessages: { telegram: " " },
diff --git a/src/plugins/types.ts b/src/plugins/types.ts
index e1f35e68dde..17a256e6c6d 100644
--- a/src/plugins/types.ts
+++ b/src/plugins/types.ts
@@ -1963,6 +1963,8 @@ export type OpenClawPluginCommandDefinition = {
};
/** Description shown in /help and command menus */
description: string;
+ /** Localized descriptions for native command surfaces that support them. */
+ descriptionLocalizations?: Record;
/** Optional system-prompt guidance for agents when this command is registered. */
agentPromptGuidance?: readonly string[];
/** Whether this command accepts arguments */