Skip to main content

Discord

Everything specific to connecting a Discord bot: what to set up on Discord's side, the two flows a bot can serve, who may invoke it, and why a silent bot is usually silent.

Overview​

Discord is bring-your-own-app: you create a Discord application, and connecting it supplies its application_id and its bot token. The token is write-only like every other channel credential — reads report has_credential, never the value — and an application backs exactly one channel, so a second connect for the same application_id is a 409 application_in_use.

Everything a channel does regardless of kind — the default action, the route table, conversations, addresses — is on the Channels page. This page is only the Discord half.

To connect one end to end, follow Connect a Discord channel.

Key Concepts​

Discord-side setup​

Three things happen on Discord's site, not naturali's API. The third is the one people skip. Connect a Discord channel walks through all three for the dms flow.

  1. Create the application at the Discord Developer Portal and copy its Application ID from General Information.
  2. Create the bot token on the Bot tab (Reset Token). Discord shows it exactly once.
  3. Add the bot to a server. On the OAuth2 tab, use the URL Generator: tick the bot scope, tick the permissions from the table below, then open the generated URL and pick a server.
A bot that is in no server cannot be reached at all

Step 3 is not optional, including for the DM-only flow: Discord lets a person open a DM with a bot only when they share a server with it. Skip it and the bot is unreachable everywhere, with nothing to see on naturali's side — no conversation is created, because no message was ever delivered.

Permissions the bot needs​

Permissions are a separate Discord concept from intents, granted when you invite the bot (and overridable per channel in the server's settings). Grant only what the flows you enable actually use:

PermissionNeeded forWhat breaks without it
View Channelmention_threadsThe bot never receives messages in that channel, so a mention does nothing.
Create Public Threadsmention_threadsThe mention is seen, the thread is never opened, and no reply is sent.
Send Messages in Threadsmention_threadsThe thread opens and then stays empty — the reply cannot be posted.

The dms flow needs none of these: a direct message is not in a server, so no server permission applies to it — only the shared-server requirement above.

A per-channel override in the server's settings outranks what the invite granted, so a bot that works in one channel and not another is usually a channel-level permission difference, not a naturali configuration difference.

Two flows, chosen with modes​

modes picks which flows the bot serves:

ModeShapeIntent it needs
dmsThe bot converses 1:1 in direct messages — the WhatsApp shape, one conversation per user.DIRECT_MESSAGES, non-privileged.
mention_threadsAn allowed member @mentions the bot in a server; naturali opens a public thread and answers inside it. The thread is the conversation.MESSAGE_CONTENT, privileged.

dms is on and mention_threads is off by default, and the reason is the privileged intent: the @mention itself arrives with its content, but the follow-up messages inside the thread do not, so the thread flow only works once you enable MESSAGE_CONTENT on your own application — and Discord requires bot verification past roughly 100 servers. A channel identifies with only the intents its enabled flows need, so a DM-only bot never asks for the privileged one. The other intents the thread flow uses (GUILDS, GUILD_MESSAGES) are not privileged and need nothing enabled in the portal.

A thread is one conversation shared by several people, which is worth knowing before you enable it: everyone in the thread talks to the same session and its memory, so it is the right shape for a shared support thread and the wrong one for anything personal.

PATCH /v1/projects/{project_id}/channels/{channel_id} rotates the bot token, flips the modes, or disables the channel. Any of those cycles the bot's connection, so a change takes effect within about half a minute rather than instantly.

Turn the thread flow on — after enabling MESSAGE_CONTENT on the application:

naturali update-channel \
--channel-id chan_V1StGXR8Z5jdHi6B \
--modes '{ "dms": true, "mention_threads": true }'

Mentioning the bot​

The thread flow opens on an @mention of the bot user. When an app joins a server Discord also creates a role with the same name, and the two render identically in the message — same text, same colour, same chip. A mention of that role counts as a mention of the bot, since picking it is an easy mistake. A mention of any other role is ignored, silently and with nothing recorded.

The role is recognised by the bot's id Discord stamps on it, so a role you created yourself with the bot's name is not the bot's role. When in doubt, pick the entry showing the bot's avatar and the APP badge.

This applies only to the message that opens the thread. Replies inside a thread the bot already owns need no mention at all — that is exactly what the MESSAGE_CONTENT intent buys, and why a bot can look like it answers in threads but ignores the channel.

A thread is owned only while it resolves to agent. Once it resolves to oneshot or message, replies there need a mention again, as they would anywhere else.

A mention with nothing else in it​

A mention with nothing else in it is ignored, unless the winning action's config.discord says what to do with it:

KeyWhat a bare mention does
use_replied_message: trueWhen it is a reply, the agent gets the text of the message it answers
empty_mention_text: "<text>"Otherwise, the bot answers with that text, addressing the sender, without a run
{
"discord": {
"use_replied_message": true,
"empty_mention_text": "Mention me with what to file, e.g. @Terezinha review the pricing page."
}
}

Replying to any message with only the mention is how somebody marks it. A reply that says something of its own is taken as written, not as the message it answers.

Other people mentioned in the message​

The bot's own mention is removed before the agent reads the message. Anyone else mentioned — in the message or in the one a bare reply files — reaches the agent as @name (their server nickname, else their display name, else their username) rather than as Discord's <@id>.

Who may talk to a Discord bot​

Server messages carry the sending member's roles, so admission is decided before the agent is invoked — an unlisted member costs you nothing. It is configured on the config of whichever action wins resolution: an address's own, else the matching route's, else the channel default's.

{
"discord": {
"allowed_role_ids": ["1290000000000000042"],
"allowed_user_ids": ["112233445566778899"]
}
}

A member is admitted by matching either list. Empty or absent lists mean everyone, so the default stays open — set at least one when the server is not already a trusted room.

How a oneshot answers​

A oneshot has no thread of its own to answer in — it keeps nothing — so config.discord.reply says what it does instead:

replyWhat the bot does
react (default)Adds config.discord.reaction (\u2705 unless you name another) to the message that triggered the run, when the run called a tool
mentionAnswers in the same channel, addressing whoever asked
react_or_mentionReacts like react when the agent writes nothing, and answers like mention when it writes a line
threadOpens a thread from the message and answers inside it
noneDelivers nothing
{ "discord": { "reply": "react", "reaction": "\ud83d\udce5" } }

A run that fails delivers nothing, whichever mode is set. A reaction also needs the run to have called a tool: an agent that answers without calling one filed nothing, so it gets no reaction, and an agent with no tools never reacts. A run whose content is not kept — trace_content_mode: none on the project or the agent — cannot be read back, so it never reacts either. There is deliberately no failure marker — a oneshot that could not run looks exactly like one that was never triggered.

A oneshot runs once per message. When Discord delivers a message again after the bot reconnects, the agent does not run again and nothing is delivered twice.

This is read for oneshot only. A conversational agent in a server is its thread — that is what its session is keyed on — so it always opens one.

Telling a tool who sent the message​

A oneshot keeps no actor, so a tool has no way to know who asked unless it is told. config.discord.forward_user_id tells it:

{ "discord": { "reply": "react", "forward_user_id": true } }

Each run then carries the sender's Discord user id as the discord_user_id tool context, which a tool's headers read as {{context:discord_user_id}}. The value is the author id Discord reported for the message, never text the model wrote, so the tool can trust it to decide whose data it acts on. It is off unless set, because tool context reaches every tool the agent has, including ones run by a third party.

When the bot does not answer​

A Discord bot fails quietly by design: a message that is not the bot's to answer is dropped rather than replied to with an error, so "no reply" is one symptom with several causes. Work down this list — it is ordered by how often each one is the answer.

SymptomLikely cause
Nothing works anywhere, DMs includedThe bot was never added to a server — see Discord-side setup.
A mention in a server channel does nothingThe mention hit a role other than the bot's own, such as one you created with its name — see Mentioning the bot.
Mentions do nothing, in every channelmention_threads is off, or MESSAGE_CONTENT is not enabled on the application.
The first mention works, follow-ups in the thread are ignoredMESSAGE_CONTENT is not enabled — the opening mention carries its content, the follow-ups do not.
A thread opens but stays emptyMissing Send Messages in Threads in that channel.
It works in one channel, not anotherA per-channel permission override in the server's settings.
It stopped right after a connect or a modes changeThe worker picks the change up on its next poll — give it about half a minute.
The reply arrives but carries the model's reasoning or JSONThe reply is delivered verbatim, so this is the agent, not the channel — constrain it in the instructions, and see Do not bind an agent that has an output_schema.

One read separates "never arrived" from "arrived and failed": GET /v1/projects/{project_id}/channels/{channel_id}/conversations. Connect a Discord channel makes this read, and the messages read below, after a working DM.

naturali list-channel-conversations --channel-id chan_V1StGXR8Z5jdHi6B

A row whose identifier matches the surface you tried — discord:dm:<user_id> or discord:thread:<guild_id>:<channel_id> — means the message reached naturali and resolved; the problem is after that point, so look at the agent and at the bot's permission to post its reply. No row means the message never got that far: everything above the fold in the table — invite, mention target, modes, intent, View Channel.

Reading the conversation's messages (GET /v1/projects/{project_id}/channels/{channel_id}/conversations/{conversation_id}/messages) narrows it once more: a user message with no assistant message after it puts the failure between the agent and delivery, not on the way in.