Skip to main content

Chat commands

Plugins can register chat commands that the gateway handles directly — without involving the LLM. When a user sends /my-command arg1 arg2, the gateway routes it to your handler and sends the reply immediately. Use this for fast utility responses, status lookups, or in-chat controls that don’t need an AI agent. Chat commands are distinct from agent tools: tools extend what the agent can call during a run; commands intercept messages before the agent runs.

Registering a command

import type { OpenClawPluginApi } from "openclaw/plugin-sdk";

export default function register(api: OpenClawPluginApi): void {
  api.registerCommand({
    name: "ping",
    description: "Check if the gateway is reachable",
    acceptsArgs: false,
    requireAuth: true,
    handler: async (ctx) => {
      return { text: "pong" };
    },
  });
}
The command is available in any channel the user has configured. No restart is needed when a plugin loads.

OpenClawPluginCommandDefinition fields

FieldTypeRequiredDescription
namestringYesCommand name without leading slash (e.g. "ping", "status"). Lowercase, no spaces.
descriptionstringYesShown in /help and native Telegram/Discord command menus
acceptsArgsbooleanNoWhether the command accepts text after the name (default false)
requireAuthbooleanNoOnly authorized senders can invoke this command (default true)
handlerPluginCommandHandlerYesThe function that runs when the command fires

The handler

The handler receives a PluginCommandContext:
FieldTypeDescription
senderIdstring | undefinedSender identifier (channel-specific format)
channelstringChannel surface — "telegram", "discord", "whatsapp", etc.
channelIdChannelId | undefinedProvider channel id
isAuthorizedSenderbooleanWhether the sender is on the allowlist
argsstring | undefinedRaw text after the command name (present when acceptsArgs: true)
commandBodystringFull normalized command body
configOpenClawConfigThe resolved gateway config
fromInbound context sender (channel-specific)
accountIdstring | undefinedThe channel account id
messageThreadIdThread id (Telegram forum topics, Discord threads)
Return a ReplyPayload from the handler:
// Minimal text reply
return { text: "Done." };

// Rich reply with media
return {
  text: "Here is your file:",
  mediaUrl: "https://example.com/file.pdf",
};

// Error reply
return { text: "Something went wrong.", isError: true };

Using command arguments

api.registerCommand({
  name: "echo",
  description: "Echo text back",
  acceptsArgs: true,
  handler: async (ctx) => {
    const input = ctx.args?.trim();
    if (!input) {
      return { text: "Usage: /echo <text>" };
    }
    return { text: input };
  },
});

Access control

requireAuth: true (the default) means only senders on the channel’s allowFrom list can invoke the command. Unauthorized senders receive no response and the invocation is silently dropped. Set requireAuth: false for commands intended for all users (e.g. a public status command):
api.registerCommand({
  name: "status",
  description: "Check gateway status (public)",
  requireAuth: false,
  handler: async (_ctx) => {
    return { text: "Gateway is running." };
  },
});
Commands with requireAuth: false respond to any sender regardless of channel policy. Do not return sensitive information from unauthenticated commands.

Name collision

If two plugins register the same command name, both register but only one runs (last registration wins). Use namespaced names to avoid collisions: my-plugin_status rather than status.

Async safety

Handlers are called in the gateway’s event loop. Do not perform synchronous blocking I/O inside a handler.
// ❌ Blocks the event loop
handler: (_ctx) => {
  const data = fs.readFileSync("./config.json", "utf8");
  return { text: data };
}

// ✅ Async I/O
handler: async (_ctx) => {
  const data = await fs.promises.readFile("./config.json", "utf8");
  return { text: data };
}

What’s next