docs/channels/discord.md
Ready for DMs and guild channels via the official Discord gateway.
<CardGroup cols={3}> <Card title="Pairing" icon="link" href="/channels/pairing"> Discord DMs default to pairing mode. </Card> <Card title="Slash commands" icon="terminal" href="/tools/slash-commands"> Native command behavior and command catalog. </Card> <Card title="Channel troubleshooting" icon="wrench" href="/channels/troubleshooting"> Cross-channel diagnostics and repair flow. </Card> </CardGroup>You will need to create a new application with a bot, add the bot to your server, and pair it to OpenClaw. We recommend adding your bot to your own private server. If you don't have one yet, create one first (choose Create My Own > For me and my friends).
<Steps> <Step title="Create a Discord application and bot"> Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Name it something like "OpenClaw".Click **Bot** on the sidebar. Set the **Username** to whatever you call your OpenClaw agent.
- **Message Content Intent** (required)
- **Server Members Intent** (recommended; required for role allowlists and name-to-ID matching)
- **Presence Intent** (optional; only needed for presence updates)
<Note>
Despite the name, this generates your first token — nothing is being "reset."
</Note>
Copy the token and save it somewhere. This is your **Bot Token** and you will need it shortly.
Scroll down to **OAuth2 URL Generator** and enable:
- `bot`
- `applications.commands`
A **Bot Permissions** section will appear below. Enable at least:
**General Permissions**
- View Channels
**Text Permissions**
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
This is the baseline set for normal text channels. If you plan to post in Discord threads, including forum or media channel workflows that create or continue a thread, also enable **Send Messages in Threads**.
Copy the generated URL at the bottom, paste it into your browser, select your server, and click **Continue** to connect. You should now see your bot in the Discord server.
1. Click **User Settings** (gear icon next to your avatar) → **Advanced** → toggle on **Developer Mode**
2. Right-click your **server icon** in the sidebar → **Copy Server ID**
3. Right-click your **own avatar** → **Copy User ID**
Save your **Server ID** and **User ID** alongside your Bot Token — you'll send all three to OpenClaw in the next step.
This lets server members (including bots) send you DMs. Keep this enabled if you want to use Discord DMs with OpenClaw. If you only plan to use guild channels, you can disable DMs after pairing.
export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
cat > discord.patch.json5 <<'JSON5'
{
channels: {
discord: {
enabled: true,
token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
},
},
}
JSON5
openclaw config patch --file ./discord.patch.json5 --dry-run
openclaw config patch --file ./discord.patch.json5
openclaw gateway
If OpenClaw is already running as a background service, restart it via the OpenClaw Mac app or by stopping and restarting the `openclaw gateway run` process.
For managed service installs, run `openclaw gateway install` from a shell where `DISCORD_BOT_TOKEN` is present, or store the variable in `~/.openclaw/.env`, so the service can resolve the env SecretRef after restart.
If your host is blocked or rate-limited by Discord's startup application lookup, set the Discord application/client ID from the Developer Portal so startup can skip that REST call. Use `channels.discord.applicationId` for the default account, or `channels.discord.accounts.<accountId>.applicationId` when you run multiple Discord bots.
<Tabs>
<Tab title="Ask your agent">
Chat with your OpenClaw agent on any existing channel (e.g. Telegram) and tell it. If Discord is your first channel, use the CLI / config tab instead.
> "I already set my Discord bot token in config. Please finish Discord setup with User ID `<user_id>` and Server ID `<server_id>`."
</Tab>
<Tab title="CLI / config">
If you prefer file-based config, set:
{
channels: {
discord: {
enabled: true,
token: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
},
},
}
Env fallback for the default account:
DISCORD_BOT_TOKEN=...
For scripted or remote setup, write the same JSON5 block with `openclaw config patch --file ./discord.patch.json5 --dry-run` and then rerun without `--dry-run`. Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
For multiple Discord bots, keep each bot token and application ID under its account. A top-level `channels.discord.applicationId` is inherited by accounts, so only set it there when every account should use the same application ID.
{
channels: {
discord: {
enabled: true,
accounts: {
personal: {
token: { source: "env", provider: "default", id: "DISCORD_PERSONAL_TOKEN" },
applicationId: "111111111111111111",
},
work: {
token: { source: "env", provider: "default", id: "DISCORD_WORK_TOKEN" },
applicationId: "222222222222222222",
},
},
},
},
}
</Tab>
</Tabs>
<Tabs>
<Tab title="Ask your agent">
Send the pairing code to your agent on your existing channel:
> "Approve this Discord pairing code: `<CODE>`"
</Tab>
<Tab title="CLI">
openclaw pairing list discord
openclaw pairing approve discord <CODE>
</Tab>
</Tabs>
Pairing codes expire after 1 hour.
You should now be able to chat with your agent in Discord via DM.
Once DMs are working, you can set up your Discord server as a full workspace where each channel gets its own agent session with its own context. This is recommended for private servers where it's just you and your bot.
<Steps> <Step title="Add your server to the guild allowlist"> This enables your agent to respond in any channel on your server, not just DMs.<Tabs>
<Tab title="Ask your agent">
> "Add my Discord Server ID `<server_id>` to the guild allowlist"
</Tab>
<Tab title="Config">
{
channels: {
discord: {
groupPolicy: "allowlist",
guilds: {
YOUR_SERVER_ID: {
requireMention: true,
users: ["YOUR_USER_ID"],
},
},
},
},
}
</Tab>
</Tabs>
In guild channels, normal assistant final replies stay private by default. Visible Discord output must be sent explicitly with the `message` tool, so the agent can lurk by default and only post when it decides a channel reply is useful.
This means the selected model must reliably call tools. If Discord shows typing and the logs show token usage but no posted message, check the session log for assistant text with `didSendViaMessagingTool: false`. That means the model produced a private final answer instead of calling `message(action=send)`. Switch to a stronger tool-calling model, or use the config below to restore legacy automatic final replies.
<Tabs>
<Tab title="Ask your agent">
> "Allow my agent to respond on this server without having to be @mentioned"
</Tab>
<Tab title="Config">
Set `requireMention: false` in your guild config:
{
channels: {
discord: {
guilds: {
YOUR_SERVER_ID: {
requireMention: false,
},
},
},
},
}
To restore legacy automatic final replies for group/channel rooms, set `messages.groupChat.visibleReplies: "automatic"`.
</Tab>
</Tabs>
<Tabs>
<Tab title="Ask your agent">
> "When I ask questions in Discord channels, use memory_search or memory_get if you need long-term context from MEMORY.md."
</Tab>
<Tab title="Manual">
If you need shared context in every channel, put the stable instructions in `AGENTS.md` or `USER.md` (they are injected for every session). Keep long-term notes in `MEMORY.md` and access them on demand with memory tools.
</Tab>
</Tabs>
Now create some channels on your Discord server and start chatting. Your agent can see the channel name, and each channel gets its own isolated session — so you can set up #coding, #home, #research, or whatever fits your workflow.
session.dmScope=main), direct chats share the agent main session (agent:main:main).agent:<agentId>:discord:channel:<channelId>).channels.discord.dm.groupEnabled=false).agent:<agentId>:discord:slash:<userId>), while still carrying CommandTargetSessionKey to the routed conversation session.Discord forum and media channels only accept thread posts. OpenClaw supports two ways to create them:
channel:<forumId>) to auto-create a thread. The thread title uses the first non-empty line of your message.openclaw message thread create to create a thread directly. Do not pass --message-id for forum channels.Example: send to forum parent to create a thread
openclaw message send --channel discord --target channel:<forumId> \
--message "Topic title\nBody of the post"
Example: create a forum thread explicitly
openclaw message thread create --channel discord --target channel:<forumId> \
--thread-name "Topic title" --message "Body of the post"
Forum parents do not accept Discord components. If you need components, send to the thread itself (channel:<threadId>).
OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a components payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord replyToMode settings.
Supported blocks:
text, section, separator, actions, media-gallery, filestring, user, role, mentionable, channelBy default, components are single use. Set components.reusable=true to allow buttons, selects, and forms to be used multiple times until they expire.
To restrict who can click a button, set allowedUsers on that button (Discord user IDs, tags, or *). When configured, unmatched users receive an ephemeral denial.
The /model and /models slash commands open an interactive model picker with provider, model, and compatible runtime dropdowns plus a Submit step. /models add is deprecated and now returns a deprecation message instead of registering models from chat. The picker reply is ephemeral and only the invoking user can use it. Discord select menus are limited to 25 options, so add provider/* entries to agents.defaults.models when you want the picker to show dynamically discovered models only for selected providers such as openai-codex or vllm.
File attachments:
file blocks must point to an attachment reference (attachment://<filename>)media/path/filePath (single file); use media-gallery for multiple filesfilename to override the upload name when it should match the attachment referenceModal forms:
components.modal with up to 5 fieldstext, checkbox, radio, select, role-select, user-selectExample:
{
channel: "discord",
action: "send",
to: "channel:123456789012345678",
message: "Optional fallback text",
components: {
reusable: true,
text: "Choose a path",
blocks: [
{
type: "actions",
buttons: [
{
label: "Approve",
style: "success",
allowedUsers: ["123456789012345678"],
},
{ label: "Decline", style: "danger" },
],
},
{
type: "actions",
select: {
type: "string",
placeholder: "Pick an option",
options: [
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" },
],
},
},
],
modal: {
title: "Details",
triggerLabel: "Open form",
fields: [
{ type: "text", label: "Requester" },
{
type: "select",
label: "Priority",
options: [
{ label: "Low", value: "low" },
{ label: "High", value: "high" },
],
},
],
},
},
}
- `pairing` (default)
- `allowlist`
- `open` (requires `channels.discord.allowFrom` to include `"*"`)
- `disabled`
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
Multi-account precedence:
- `channels.discord.accounts.default.allowFrom` applies only to the `default` account.
- For one account, `allowFrom` takes precedence over legacy `dm.allowFrom`.
- Named accounts inherit `channels.discord.allowFrom` when their own `allowFrom` and legacy `dm.allowFrom` are unset.
- Named accounts do not inherit `channels.discord.accounts.default.allowFrom`.
Legacy `channels.discord.dm.policy` and `channels.discord.dm.allowFrom` still read for compatibility. `openclaw doctor --fix` migrates them to `dmPolicy` and `allowFrom` when it can do so without changing access.
DM target format for delivery:
- `user:<id>`
- `<@id>` mention
Bare numeric IDs normally resolve as channel IDs when a channel default is active, but IDs listed in the account's effective DM `allowFrom` are treated as user DM targets for compatibility.
Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically. Shared access-group behavior is documented here: [Access groups](/channels/access-groups).
{
accessGroups: {
operators: {
type: "message.senders",
members: {
"*": ["global-owner-id"],
discord: ["discord:123456789012345678"],
telegram: ["987654321"],
},
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:operators"],
},
},
}
A Discord text channel has no separate member list. `type: "discord.channelAudience"` models membership as: the DM sender is a member of the configured guild and currently has effective `ViewChannel` permission on the configured channel after role and channel overwrites are applied.
Example: allow anyone who can see `#maintainers` to DM the bot, while keeping DMs closed to everyone else.
{
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "1456350064065904867",
channelId: "1456744319972282449",
membership: "canViewChannel",
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:maintainers"],
},
},
}
You can mix dynamic and static entries:
{
accessGroups: {
maintainers: {
type: "discord.channelAudience",
guildId: "1456350064065904867",
channelId: "1456744319972282449",
},
},
channels: {
discord: {
dmPolicy: "allowlist",
allowFrom: ["accessGroup:maintainers", "discord:123456789012345678"],
},
},
}
Lookups fail closed. If Discord returns `Missing Access`, the member lookup fails, or the channel belongs to a different guild, the DM sender is treated as unauthorized.
Enable the Discord Developer Portal **Server Members Intent** for the bot when using channel-audience access groups. DMs do not include guild member state, so OpenClaw resolves the member through Discord REST at authorization time.
- `open`
- `allowlist`
- `disabled`
Secure baseline when `channels.discord` exists is `allowlist`.
`allowlist` behavior:
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
- optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
- direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode
- names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used
- if a guild has `channels` configured, non-listed channels are denied
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
Example:
{
channels: {
discord: {
groupPolicy: "allowlist",
guilds: {
"123456789012345678": {
requireMention: true,
ignoreOtherMentions: true,
users: ["987654321098765432"],
roles: ["123456789012345678"],
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
},
},
},
},
},
}
If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`.
Mention detection includes:
- explicit bot mention
- configured mention patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot behavior in supported cases
When writing outbound Discord messages, use canonical mention syntax: `<@USER_ID>` for users, `<#CHANNEL_ID>` for channels, and `<@&ROLE_ID>` for roles. Do not use the legacy `<@!USER_ID>` nickname mention form.
`requireMention` is configured per guild/channel (`channels.discord.guilds...`).
`ignoreOtherMentions` optionally drops messages that mention another user/role but not the bot (excluding @everyone/@here).
Group DMs:
- default: ignored (`dm.groupEnabled=false`)
- optional allowlist via `dm.groupChannels` (channel IDs or slugs)
Use bindings[].match.roles to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example peer + guildId + roles), all configured fields must match.
{
bindings: [
{
agentId: "opus",
match: {
channel: "discord",
guildId: "123456789012345678",
roles: ["111111111111111111"],
},
},
{
agentId: "sonnet",
match: {
channel: "discord",
guildId: "123456789012345678",
},
},
],
}
commands.native defaults to "auto" and is enabled for Discord.channels.discord.commands.native.commands.native=false skips Discord slash-command registration and cleanup during startup. Previously registered commands may remain visible in Discord until you remove them from the Discord app.See Slash commands for command catalog and behavior.
Default slash command settings:
ephemeral: true- `[[reply_to_current]]`
- `[[reply_to:<id>]]`
Controlled by `channels.discord.replyToMode`:
- `off` (default)
- `first`
- `all`
- `batched`
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
`first` always attaches the implicit native reply reference to the first outbound Discord message for the turn.
`batched` only attaches Discord's implicit native reply reference when the
inbound turn was a debounced batch of multiple messages. This is useful
when you want native replies mainly for ambiguous bursty chats, not every
single-message turn.
Message IDs are surfaced in context/history so agents can target specific messages.
Set `channels.discord.streaming.mode` to `off` to disable Discord preview edits. If Discord block streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming.
{
channels: {
discord: {
streaming: {
mode: "progress",
progress: {
label: "auto",
maxLines: 8,
toolProgress: true,
},
},
},
},
}
- `partial` edits a single preview message as tokens arrive.
- `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints, clamped to `textChunkLimit`).
- Media, error, and explicit-reply finals cancel pending preview edits.
- `streaming.preview.toolProgress` (default `true`) controls whether tool/progress updates reuse the preview message.
- Tool/progress rows render as compact emoji + title + detail when available, for example `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`.
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only).
Hide raw command/exec text while keeping compact progress lines:
```json
{
"channels": {
"discord": {
"streaming": {
"mode": "progress",
"progress": {
"toolProgress": true,
"commandText": "status"
}
}
}
}
}
```
Preview streaming is text-only; media replies fall back to normal delivery. When `block` streaming is explicitly enabled, OpenClaw skips the preview stream to avoid double-streaming.
- `channels.discord.historyLimit` default `20`
- fallback: `messages.groupChat.historyLimit`
- `0` disables
DM history controls:
- `channels.discord.dmHistoryLimit`
- `channels.discord.dms["<user_id>"].historyLimit`
Thread behavior:
- Discord threads route as channel sessions and inherit parent channel config unless overridden.
- Thread sessions inherit the parent channel's session-level `/model` selection as a model-only fallback; thread-local `/model` selections still take precedence and parent transcript history is not copied unless transcript inheritance is enabled.
- `channels.discord.thread.inheritParent` (default `false`) opts new auto-threads into seeding from the parent transcript. Per-account overrides live under `channels.discord.accounts.<id>.thread.inheritParent`.
- Message-tool reactions can resolve `user:<id>` DM targets.
- `guilds.<guild>.channels.<channel>.requireMention: false` is preserved during reply-stage activation fallback.
Channel topics are injected as **untrusted** context. Allowlists gate who can trigger the agent, not a full supplemental-context redaction boundary.
Commands:
- `/focus <target>` bind current/new thread to a subagent/session target
- `/unfocus` remove current thread binding
- `/agents` show active runs and binding state
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
- `/session max-age <duration|off>` inspect/update hard max age for focused bindings
Config:
{
session: {
threadBindings: {
enabled: true,
idleHours: 24,
maxAgeHours: 0,
},
},
channels: {
discord: {
threadBindings: {
enabled: true,
idleHours: 24,
maxAgeHours: 0,
spawnSessions: true,
defaultSpawnContext: "fork",
},
},
},
}
Notes:
- `session.threadBindings.*` sets global defaults.
- `channels.discord.threadBindings.*` overrides Discord behavior.
- `spawnSessions` controls auto-create/bind threads for `sessions_spawn({ thread: true })` and ACP thread spawns. Default: `true`.
- `defaultSpawnContext` controls native subagent context for thread-bound spawns. Default: `"fork"`.
- Deprecated `spawnSubagentSessions`/`spawnAcpSessions` keys are migrated by `openclaw doctor --fix`.
- If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable.
See [Sub-agents](/tools/subagents), [ACP Agents](/tools/acp-agents), and [Configuration Reference](/gateway/configuration-reference).
Config path:
- `bindings[]` with `type: "acp"` and `match.channel: "discord"`
Example:
{
agents: {
list: [
{
id: "codex",
runtime: {
type: "acp",
acp: {
agent: "codex",
backend: "acpx",
mode: "persistent",
cwd: "/workspace/openclaw",
},
},
},
],
},
bindings: [
{
type: "acp",
agentId: "codex",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: "222222222222222222" },
},
acp: { label: "codex-main" },
},
],
channels: {
discord: {
guilds: {
"111111111111111111": {
channels: {
"222222222222222222": {
requireMention: false,
},
},
},
},
},
},
}
Notes:
- `/acp spawn codex --bind here` binds the current channel or thread in place and keeps future messages on the same ACP session. Thread messages inherit the parent channel binding.
- In a bound channel or thread, `/new` and `/reset` reset the same ACP session in place. Temporary thread bindings can override target resolution while active.
- `spawnSessions` gates child thread creation/binding via `--thread auto|here`.
See [ACP Agents](/tools/acp-agents) for binding behavior details.
- `off`
- `own` (default)
- `all`
- `allowlist` (uses `guilds.<id>.users`)
Reaction events are turned into system events and attached to the routed Discord session.
Resolution order:
- `channels.discord.accounts.<accountId>.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.
This affects `/config set|unset` flows (when command features are enabled).
Disable:
{
channels: {
discord: {
configWrites: false,
},
},
}
{
channels: {
discord: {
proxy: "http://proxy.example:8080",
},
},
}
Per-account override:
{
channels: {
discord: {
accounts: {
primary: {
proxy: "http://proxy.example:8080",
},
},
},
},
}
{
channels: {
discord: {
pluralkit: {
enabled: true,
token: "pk_live_...", // optional; needed for private systems
},
},
},
}
Notes:
- allowlists can use `pk:<memberId>`
- member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true`
- lookups use original message ID and are time-window constrained
- if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true`
{
channels: {
discord: {
mentionAliases: {
Vladislava: "123456789012345678",
},
accounts: {
ops: {
mentionAliases: {
OpsLead: "234567890123456789",
},
},
},
},
},
}
Status only example:
{
channels: {
discord: {
status: "idle",
},
},
}
Activity example (custom status is the default activity type):
{
channels: {
discord: {
activity: "Focus time",
activityType: 4,
},
},
}
Streaming example:
{
channels: {
discord: {
activity: "Live coding",
activityType: 1,
activityUrl: "https://twitch.tv/openclaw",
},
},
}
Activity type map:
- 0: Playing
- 1: Streaming (requires `activityUrl`)
- 2: Listening
- 3: Watching
- 4: Custom (uses the activity text as the status state; emoji is optional)
- 5: Competing
Auto presence example (runtime health signal):
{
channels: {
discord: {
autoPresence: {
enabled: true,
intervalMs: 30000,
minUpdateIntervalMs: 15000,
exhaustedText: "token exhausted",
},
},
},
}
Auto presence maps runtime availability to Discord status: healthy => online, degraded or unknown => idle, exhausted or unavailable => dnd. Optional text overrides:
- `autoPresence.healthyText`
- `autoPresence.degradedText`
- `autoPresence.exhaustedText` (supports `{reason}` placeholder)
Config path:
- `channels.discord.execApprovals.enabled`
- `channels.discord.execApprovals.approvers` (optional; falls back to `commands.ownerAllowFrom` when possible)
- `channels.discord.execApprovals.target` (`dm` | `channel` | `both`, default: `dm`)
- `agentFilter`, `sessionFilter`, `cleanupAfterResolve`
Discord auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `commands.ownerAllowFrom`. Discord does not infer exec approvers from channel `allowFrom`, legacy `dm.allowFrom`, or direct-message `defaultTo`. Set `enabled: false` to disable Discord as a native approval client explicitly.
For sensitive owner-only group commands such as `/diagnostics` and `/export-trajectory`, OpenClaw sends approval prompts and final results privately. It tries Discord DM first when the invoking owner has a Discord owner route; if that is not available, it falls back to the first available owner route from `commands.ownerAllowFrom`, such as Telegram.
When `target` is `channel` or `both`, the approval prompt is visible in the channel. Only resolved approvers can use the buttons; other users receive an ephemeral denial. Approval prompts include the command text, so only enable channel delivery in trusted channels. If the channel ID cannot be derived from the session key, OpenClaw falls back to DM delivery.
Discord also renders the shared approval buttons used by other chat channels. The native Discord adapter mainly adds approver DM routing and channel fanout.
When those buttons are present, they are the primary approval UX; OpenClaw
should only include a manual `/approve` command when the tool result says
chat approvals are unavailable or manual approval is the only path.
If the Discord native approval runtime is not active, OpenClaw keeps the
local deterministic `/approve <id> <decision>` prompt visible. If the
runtime is active but a native card cannot be delivered to any target,
OpenClaw sends a same-chat fallback notice with the exact `/approve`
command from the pending approval.
Gateway auth and approval resolution follow the shared Gateway client contract (`plugin:` IDs resolve through `plugin.approval.resolve`; other IDs through `exec.approval.resolve`). Approvals expire after 30 minutes by default.
See [Exec approvals](/tools/exec-approvals).
Discord message actions include messaging, channel admin, moderation, presence, and metadata actions.
Core examples:
sendMessage, readMessages, editMessage, deleteMessage, threadReplyreact, reactions, emojiListtimeout, kick, bansetPresenceThe event-create action accepts an optional image parameter (URL or local file path) to set the scheduled event cover image.
Action gates live under channels.discord.actions.*.
Default gate behavior:
| Action group | Default |
|---|---|
| reactions, messages, threads, pins, polls, search, memberInfo, roleInfo, channelInfo, channels, voiceStatus, events, stickers, emojiUploads, stickerUploads, permissions | enabled |
| roles | disabled |
| moderation | disabled |
| presence | disabled |
OpenClaw uses Discord components v2 for exec approvals and cross-context markers. Discord message actions can also accept components for custom UI (advanced; requires constructing a component payload via the discord tool), while legacy embeds remain available but are not recommended.
channels.discord.ui.components.accentColor sets the accent color used by Discord component containers (hex).channels.discord.accounts.<id>.ui.components.accentColor.embeds are ignored when components v2 are present.Example:
{
channels: {
discord: {
ui: {
components: {
accentColor: "#5865F2",
},
},
},
},
}
Discord has two distinct voice surfaces: realtime voice channels (continuous conversations) and voice message attachments (the waveform preview format). The gateway supports both.
Setup checklist:
bot and applications.commands scopes.commands.native or channels.discord.commands.native).channels.discord.voice.Use /vc join|leave|status to control sessions. The command uses the account default agent and follows the same allowlist and group policy rules as other Discord commands.
/vc join channel:<voice-channel-id>
/vc status
/vc leave
To inspect the bot's effective permissions before joining, run:
openclaw channels capabilities --channel discord --target channel:<voice-channel-id>
Auto-join example:
{
channels: {
discord: {
voice: {
enabled: true,
model: "openai-codex/gpt-5.5",
autoJoin: [
{
guildId: "123456789012345678",
channelId: "234567890123456789",
},
],
allowedChannels: [
{
guildId: "123456789012345678",
channelId: "234567890123456789",
},
],
daveEncryption: true,
decryptionFailureTolerance: 24,
connectTimeoutMs: 30000,
reconnectGraceMs: 15000,
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
},
},
},
},
}
Notes:
voice.tts overrides messages.tts for stt-tts voice playback only. Realtime modes use voice.realtime.voice.voice.mode controls the conversation path. The default is agent-proxy: a realtime voice front end handles turn timing, interruption, and playback, delegates substantive work to the routed OpenClaw agent through openclaw_agent_consult, and treats the result like a typed Discord prompt from that speaker. stt-tts keeps the older batch STT plus TTS flow. bidi lets the realtime model converse directly while exposing openclaw_agent_consult for the OpenClaw brain.voice.agentSession controls which OpenClaw conversation receives voice turns. Leave it unset for the voice channel's own session, or set { mode: "target", target: "channel:<text-channel-id>" } to make the voice channel act as the microphone/speaker extension of an existing Discord text channel session such as #maintainers.voice.model overrides the OpenClaw agent brain for Discord voice responses and realtime consults. Leave it unset to inherit the routed agent model. It is separate from voice.realtime.model.agent-proxy routes speech through discord-voice, which preserves normal owner/tool authorization for the speaker and target session but hides the agent tts tool because Discord voice owns playback. By default, agent-proxy gives the consult full owner-equivalent tool access for owner speakers (voice.realtime.toolPolicy: "owner") and strongly prefers consulting the OpenClaw agent before substantive answers (voice.realtime.consultPolicy: "always"). In that default always mode, the realtime layer does not auto-speak filler before the consult answer; it captures and transcribes speech, then speaks the routed OpenClaw answer. If multiple forced consult answers finish while Discord is still playing the first answer, later exact-speech answers are queued until playback idles instead of replacing speech mid-sentence.stt-tts mode, STT uses tools.media.audio; voice.model does not affect transcription.voice.realtime.provider, voice.realtime.model, and voice.realtime.voice configure the realtime audio session. For OpenAI Realtime 2 plus the Codex brain, use voice.realtime.model: "gpt-realtime-2" and voice.model: "openai-codex/gpt-5.5".voice.realtime.bargeIn controls whether Discord speaker-start events interrupt active realtime playback. If unset, it follows the realtime provider's input-audio interruption setting.voice.realtime.minBargeInAudioEndMs controls the minimum assistant playback duration before an OpenAI realtime barge-in truncates audio. Default: 250. Set 0 for immediate interruption in low-echo rooms, or raise it for echo-heavy speaker setups.voice.tts.provider: "openai" and choose a Text-to-speech voice under voice.tts.openai.voice or voice.tts.providers.openai.voice. cedar is a good masculine-sounding choice on the current OpenAI TTS model.systemPrompt overrides apply to voice transcript turns for that voice channel.allowFrom (or dm.allowFrom); non-owner speakers cannot access owner-only tools (for example gateway and cron).channels.discord.voice.enabled=true (or keep an existing channels.discord.voice block) to enable /vc commands, the voice runtime, and the GuildVoiceStates gateway intent.channels.discord.intents.voiceStates can explicitly override voice-state intent subscription. Leave it unset for the intent to follow effective voice enablement.voice.autoJoin has multiple entries for the same guild, OpenClaw joins the last configured channel for that guild.voice.allowedChannels is an optional residency allowlist. Leave it unset to allow /vc join into any authorized Discord voice channel. When set, /vc join, startup auto-join, and bot voice-state moves are restricted to the listed { guildId, channelId } entries. Set it to an empty array to deny all Discord voice joins. If Discord moves the bot outside the allowlist, OpenClaw leaves that channel and rejoins the configured auto-join target when one is available.voice.daveEncryption and voice.decryptionFailureTolerance pass through to @discordjs/voice join options.@discordjs/voice defaults are daveEncryption=true and decryptionFailureTolerance=24 if unset.opusscript decoder for Discord voice receive. The optional native @discordjs/opus package is ignored by the repo pnpm install policy so normal installs, Docker lanes, and unrelated tests do not compile a native addon. Dedicated voice-performance hosts can opt in with OPENCLAW_DISCORD_OPUS_DECODER=native after installing the native addon.voice.connectTimeoutMs controls the initial @discordjs/voice Ready wait for /vc join and auto-join attempts. Default: 30000.voice.reconnectGraceMs controls how long OpenClaw waits for a disconnected voice session to begin reconnecting before destroying it. Default: 15000.stt-tts mode, voice playback does not stop just because another user starts speaking. To avoid feedback loops, OpenClaw ignores new voice capture while TTS is playing; speak after playback finishes for the next turn. Realtime modes forward speaker starts as barge-in signals to the realtime provider.voice.realtime.providers.openai.interruptResponseOnInputAudio: false to keep OpenAI from auto-interrupting on input audio. Add voice.realtime.bargeIn: true if you still want Discord speaker-start events to interrupt active playback. The OpenAI realtime bridge ignores playback truncations shorter than voice.realtime.minBargeInAudioEndMs as likely echo/noise and logs them as skipped instead of clearing Discord playback.voice.captureSilenceGraceMs controls how long OpenClaw waits after Discord reports a speaker has stopped before finalizing that audio segment for STT. Default: 2500; raise this if Discord splits normal pauses into choppy partial transcripts.DecryptionFailed(UnencryptedWhenPassthroughDisabled) after updating, collect a dependency report and logs. The bundled @discordjs/voice line includes the upstream padding fix from discord.js PR #11449, which closed discord.js issue #11419.The operation was aborted receive events are expected when OpenClaw finalizes a captured speaker segment; they are verbose diagnostics, not warnings.agent-proxy mode, forced consult fallback skips likely incomplete transcript fragments such as text ending in ... or a trailing connector like and, plus obvious non-actionable closings like “be right back” or “bye”. Logs show forced agent consult skipped reason=... when this prevents a stale queued answer.Native opus setup for source checkouts:
pnpm install
mise exec node@22 -- pnpm discord:opus:install
Use Node 22 for the gateway when you want the upstream macOS arm64 prebuilt native addon. If you use another Node runtime, the opt-in installer may need a local node-gyp source-build toolchain.
After installing the native addon, start the Gateway with:
OPENCLAW_DISCORD_OPUS_DECODER=native pnpm gateway:watch
Verbose voice logs should show discord voice: opus decoder: @discordjs/opus. Without the env opt-in, or if the native addon is missing or cannot load on the host, OpenClaw logs discord voice: opus decoder: opusscript and keeps receiving voice through the pure-JS fallback.
STT plus TTS pipeline:
tools.media.audio handles STT, for example openai/gpt-4o-mini-transcribe.tts tool and asks for returned text, because Discord voice owns final TTS playback.voice.model, when set, overrides only the response LLM for this voice-channel turn.voice.tts is merged over messages.tts; streaming-capable providers feed the player directly, otherwise the resulting audio file is played in the joined channel.Default agent-proxy voice-channel session example:
{
channels: {
discord: {
voice: {
enabled: true,
model: "openai-codex/gpt-5.5",
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
},
},
},
},
}
With no voice.agentSession block, each voice channel gets its own routed OpenClaw session. For example, /vc join channel:234567890123456789 talks to the session for that Discord voice channel. The realtime model is only the voice front end; substantive requests are handed to the configured OpenClaw agent. If the realtime model produces a final transcript without calling the consult tool, OpenClaw forces the consult as a fallback so the default still behaves like talking to the agent.
Legacy STT plus TTS example:
{
channels: {
discord: {
voice: {
enabled: true,
mode: "stt-tts",
model: "openai/gpt-5.4-mini",
tts: {
provider: "openai",
openai: {
model: "gpt-4o-mini-tts",
voice: "cedar",
},
},
},
},
},
}
Realtime bidi example:
{
channels: {
discord: {
voice: {
enabled: true,
mode: "bidi",
model: "openai-codex/gpt-5.5",
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
toolPolicy: "safe-read-only",
consultPolicy: "always",
},
},
},
},
}
Voice as an extension of an existing Discord channel session:
{
channels: {
discord: {
voice: {
enabled: true,
mode: "agent-proxy",
model: "openai-codex/gpt-5.5",
agentSession: {
mode: "target",
target: "channel:123456789012345678",
},
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
},
},
},
},
}
In agent-proxy mode the bot joins the configured voice channel, but OpenClaw agent turns use the target channel's normal routed session and agent. The realtime voice session speaks the returned result back into the voice channel. The supervisor agent can still use normal message tools according to its tool policy, including sending a separate Discord message if that is the right action.
Useful target forms:
target: "channel:123456789012345678" routes through a Discord text channel session.target: "123456789012345678" is treated as a channel target.target: "dm:123456789012345678" or target: "user:123456789012345678" routes through that direct-message session.Echo-heavy OpenAI Realtime example:
{
channels: {
discord: {
voice: {
enabled: true,
mode: "bidi",
model: "openai-codex/gpt-5.5",
realtime: {
provider: "openai",
model: "gpt-realtime-2",
voice: "cedar",
bargeIn: true,
minBargeInAudioEndMs: 500,
consultPolicy: "always",
providers: {
openai: {
interruptResponseOnInputAudio: false,
},
},
},
},
},
},
}
Use this when the model hears its own Discord playback through an open mic, but you still want to interrupt it by speaking. OpenClaw keeps OpenAI from auto-interrupting on raw input audio, while bargeIn: true lets Discord speaker-start events and already-active speaker audio cancel active realtime responses before the next captured turn reaches OpenAI. Very early barge-in signals with audioEndMs below minBargeInAudioEndMs are treated as likely echo/noise and ignored so the model does not cut off at the first playback frame.
Expected voice logs:
discord voice: joining ... voiceSession=... supervisorSession=... agentSessionMode=... voiceModel=... realtimeModel=...discord voice: realtime bridge starting ... autoRespond=false interruptResponse=false bargeIn=false minBargeInAudioEndMs=...discord voice: realtime speaker turn opened ..., discord voice: realtime input audio started ... outputAudioMs=... outputActive=..., and discord voice: realtime speaker turn closed ... chunks=... discordBytes=... realtimeBytes=... interruptedPlayback=...discord voice: realtime forced agent consult skipped reason=incomplete-transcript ... or reason=non-actionable-closing ...discord voice: realtime audio playback finishing reason=response.done ... audioMs=... chunks=...discord voice: realtime audio playback stopped reason=... audioMs=... elapsedMs=... chunks=...discord voice: realtime consult requested ... voiceSession=... supervisorSession=... question=...discord voice: agent turn answer ...discord voice: realtime exact speech queued ... queued=... outputAudioMs=... outputActive=..., followed by discord voice: realtime exact speech dequeued reason=player-idle ...discord voice: realtime barge-in detected source=speaker-start ... or discord voice: realtime barge-in detected source=active-speaker-audio ..., followed by discord voice: realtime barge-in requested reason=... outputAudioMs=... outputActive=...discord voice: realtime model interrupt requested client:response.cancel reason=barge-in, followed by either discord voice: realtime model audio truncated client:conversation.item.truncate reason=barge-in audioEndMs=... or discord voice: realtime model interrupt confirmed server:response.done status=cancelled ...discord voice: realtime model interrupt ignored client:conversation.item.truncate.skipped reason=barge-in audioEndMs=0 minAudioEndMs=250discord voice: realtime capture ignored during playback (barge-in disabled) ...discord voice: realtime barge-in ignored reason=... outputActive=false ... playbackChunks=0To debug cut-off audio, read the realtime voice logs as a timeline:
realtime audio playback started means Discord has begun playing assistant audio. The bridge starts counting assistant output chunks, Discord PCM bytes, provider realtime bytes, and synthesized audio duration from this point.realtime speaker turn opened marks a Discord speaker becoming active. If playback is already active and bargeIn is enabled, this can be followed by barge-in detected source=speaker-start.realtime input audio started marks the first actual audio frame received for that speaker turn. outputActive=true or a nonzero outputAudioMs here means the mic is sending input while assistant playback is still active.barge-in detected source=active-speaker-audio means OpenClaw saw live speaker audio while assistant playback was active. This is useful for distinguishing a real interruption from a Discord speaker-start event with no useful audio.barge-in requested reason=... means OpenClaw asked the realtime provider to cancel or truncate the active response. It includes outputAudioMs, outputActive, and playbackChunks so you can see how much assistant audio had actually played before the interruption.realtime audio playback stopped reason=... is the local Discord playback reset point. The reason says who stopped playback: barge-in, player-idle, provider-clear-audio, forced-agent-consult, stream-close, or session-close.realtime speaker turn closed summarizes the captured input turn. chunks=0 or hasAudio=false means the speaker turn opened but no usable audio reached the realtime bridge. interruptedPlayback=true means that input turn overlapped assistant output and triggered barge-in logic.Useful fields:
outputAudioMs: assistant audio duration generated by the realtime provider before the log line.audioMs: assistant audio duration that OpenClaw counted before playback stopped.elapsedMs: wall-clock time between opening and closing the playback stream or speaker turn.discordBytes: 48 kHz stereo PCM bytes sent to or received from Discord voice.realtimeBytes: provider-format PCM bytes sent to or received from the realtime provider.playbackChunks: assistant audio chunks forwarded to Discord for the active response.sinceLastAudioMs: gap between the last captured speaker audio frame and the speaker turn closing.Common patterns:
source=active-speaker-audio, small outputAudioMs, and the same user nearby usually points to speaker echo entering the mic. Raise voice.realtime.minBargeInAudioEndMs, lower speaker volume, use headphones, or set voice.realtime.providers.openai.interruptResponseOnInputAudio: false.source=speaker-start followed by speaker turn closed ... hasAudio=false means Discord reported a speaker start but no audio reached OpenClaw. That can be a transient Discord voice event, noise gate behavior, or a client briefly keying the mic.audio playback stopped reason=stream-close without a nearby barge-in or provider-clear-audio means the local Discord playback stream ended unexpectedly. Check the preceding provider and Discord player logs.capture ignored during playback (barge-in disabled) means OpenClaw intentionally dropped input while assistant audio was active. Enable voice.realtime.bargeIn if you want speech to interrupt playback.barge-in ignored ... outputActive=false means Discord or provider VAD reported speech, but OpenClaw had no active playback to interrupt. This should not cut off audio.Credentials are resolved per component: LLM route auth for voice.model, STT auth for tools.media.audio, TTS auth for messages.tts/voice.tts, and realtime provider auth for voice.realtime.providers or the provider's normal auth config.
Discord voice messages show a waveform preview and require OGG/Opus audio. OpenClaw generates the waveform automatically, but needs ffmpeg and ffprobe on the gateway host to inspect and convert.
message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true)
- enable Message Content Intent
- enable Server Members Intent when you depend on user/member resolution
- restart gateway after changing intents
- verify `groupPolicy`
- verify guild allowlist under `channels.discord.guilds`
- if guild `channels` map exists, only listed channels are allowed
- verify `requireMention` behavior and mention patterns
Useful checks:
openclaw doctor
openclaw channels status --probe
openclaw logs --follow
- `groupPolicy="allowlist"` without matching guild/channel allowlist
- `requireMention` configured in the wrong place (must be under `channels.discord.guilds` or channel entry)
- sender blocked by guild/channel `users` allowlist
Typical logs:
- `Slow listener detected ...`
- `stuck session: sessionKey=agent:...:discord:... state=processing ...`
Discord gateway queue knobs:
- single-account: `channels.discord.eventQueue.listenerTimeout`
- multi-account: `channels.discord.accounts.<accountId>.eventQueue.listenerTimeout`
- this only controls Discord gateway listener work, not agent turn lifetime
Discord does not apply a channel-owned timeout to queued agent turns. Message listeners hand off immediately, and queued Discord runs preserve per-session ordering until the session/tool/runtime lifecycle completes or aborts the work.
{
channels: {
discord: {
accounts: {
default: {
eventQueue: {
listenerTimeout: 120000,
},
},
},
},
},
}
Metadata timeout knobs:
- single-account: `channels.discord.gatewayInfoTimeoutMs`
- multi-account: `channels.discord.accounts.<accountId>.gatewayInfoTimeoutMs`
- env fallback when config is unset: `OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS`
- default: `30000` (30 seconds), max: `120000`
READY timeout knobs:
- startup single-account: `channels.discord.gatewayReadyTimeoutMs`
- startup multi-account: `channels.discord.accounts.<accountId>.gatewayReadyTimeoutMs`
- startup env fallback when config is unset: `OPENCLAW_DISCORD_READY_TIMEOUT_MS`
- startup default: `15000` (15 seconds), max: `120000`
- runtime single-account: `channels.discord.gatewayRuntimeReadyTimeoutMs`
- runtime multi-account: `channels.discord.accounts.<accountId>.gatewayRuntimeReadyTimeoutMs`
- runtime env fallback when config is unset: `OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS`
- runtime default: `30000` (30 seconds), max: `120000`
If you use slug keys, runtime matching can still work, but probe cannot fully verify permissions.
- DM disabled: `channels.discord.dm.enabled=false`
- DM policy disabled: `channels.discord.dmPolicy="disabled"` (legacy: `channels.discord.dm.policy`)
- awaiting pairing approval in `pairing` mode
If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior.
Prefer `channels.discord.allowBots="mentions"` to only accept bot messages that mention the bot.
{
channels: {
discord: {
accounts: {
mantis: {
// Mantis listens to other bots only when they mention her.
allowBots: "mentions",
},
molty: {
// Molty listens to all bot-authored Discord messages.
allowBots: true,
mentionAliases: {
// Lets Molty write "@Mantis" and send a real Discord mention.
Mantis: "MANTIS_DISCORD_USER_ID",
},
},
},
},
},
}
- keep OpenClaw current (`openclaw update`) so the Discord voice receive recovery logic is present
- confirm `channels.discord.voice.daveEncryption=true` (default)
- start from `channels.discord.voice.decryptionFailureTolerance=24` (upstream default) and tune only if needed
- watch logs for:
- `discord voice: DAVE decrypt failures detected`
- `discord voice: repeated decrypt failures; attempting rejoin`
- if failures continue after automatic rejoin, collect logs and compare against the upstream DAVE receive history in [discord.js #11419](https://github.com/discordjs/discord.js/issues/11419) and [discord.js #11449](https://github.com/discordjs/discord.js/pull/11449)
Primary reference: Configuration reference - Discord.
<Accordion title="High-signal Discord fields">enabled, token, accounts.*, allowBotsgroupPolicy, dm.*, guilds.*, guilds.*.channels.*commands.native, commands.useAccessGroups, configWrites, slashCommand.*eventQueue.listenerTimeout (listener budget), eventQueue.maxQueueSize, eventQueue.maxConcurrencygatewayInfoTimeoutMs, gatewayReadyTimeoutMs, gatewayRuntimeReadyTimeoutMsreplyToMode, historyLimit, dmHistoryLimit, dms.*.historyLimittextChunkLimit, chunkMode, maxLinesPerMessagestreaming (legacy alias: streamMode), streaming.preview.toolProgress, draftChunk, blockStreaming, blockStreamingCoalescemediaMaxMb (caps outbound Discord uploads, default 100MB), retryactions.*activity, status, activityType, activityUrlui.components.accentColorthreadBindings, top-level bindings[] (type: "acp"), pluralkit, execApprovals, intents, agentComponents, heartbeat, responsePrefixDISCORD_BOT_TOKEN preferred in supervised environments).openclaw channels status --probe.