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.
- Create the application at the Discord Developer Portal and copy its Application ID from General Information.
- Create the bot token on the Bot tab (Reset Token). Discord shows it exactly once.
- Add the bot to a server. On the OAuth2 tab, use the URL Generator:
tick the
botscope, tick the permissions from the table below, then open the generated URL and pick a server.
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:
| Permission | Needed for | What breaks without it |
|---|---|---|
| View Channel | mention_threads | The bot never receives messages in that channel, so a mention does nothing. |
| Create Public Threads | mention_threads | The mention is seen, the thread is never opened, and no reply is sent. |
| Send Messages in Threads | mention_threads | The 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:
| Mode | Shape | Intent it needs |
|---|---|---|
dms | The bot converses 1:1 in direct messages — the WhatsApp shape, one conversation per user. | DIRECT_MESSAGES, non-privileged. |
mention_threads | An 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:
- CLI
- SDK
- curl
naturali update-channel \
--channel-id chan_V1StGXR8Z5jdHi6B \
--modes '{ "dms": true, "mention_threads": true }'
const { data: channel } = await naturali.channels.updateChannel({
path: { project_id: PROJECT, channel_id: 'chan_V1StGXR8Z5jdHi6B' },
body: { modes: { dms: true, mention_threads: true } },
});
curl -sS -X PATCH "$NATURALI_API/projects/$PROJECT/channels/chan_V1StGXR8Z5jdHi6B" \
-H "Authorization: Bearer $NATURALI_TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "modes": { "dms": true, "mention_threads": true } }'
Mentioning the bot: the user, not the role
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. Only the user mention starts a thread; a role mention is ignored, silently and with nothing recorded.
When you type @, pick the entry showing the bot's avatar and the APP
badge. To tell them apart after the fact, click the mention in the posted
message: the bot user opens a profile card, the role does not.
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.
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 resolved action's config — today that means the channel default's
config (a route's own config works identically once you have one):
{
"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.
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.
| Symptom | Likely cause |
|---|---|
| Nothing works anywhere, DMs included | The bot was never added to a server — see Discord-side setup. |
| A mention in a server channel does nothing | The mention hit the role, not the bot user — see Mentioning the bot. |
| Mentions do nothing, in every channel | mention_threads is off, or MESSAGE_CONTENT is not enabled on the application. |
| The first mention works, follow-ups in the thread are ignored | MESSAGE_CONTENT is not enabled — the opening mention carries its content, the follow-ups do not. |
| A thread opens but stays empty | Missing Send Messages in Threads in that channel. |
| It works in one channel, not another | A per-channel permission override in the server's settings. |
It stopped right after a connect or a modes change | The 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 JSON | The 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.
- CLI
- SDK
- curl
naturali list-channel-conversations --channel-id chan_V1StGXR8Z5jdHi6B
const { data: conversations } = await naturali.channels.listChannelConversations(
{ path: { project_id: PROJECT, channel_id: 'chan_V1StGXR8Z5jdHi6B' } }
);
curl -sS "$NATURALI_API/projects/$PROJECT/channels/chan_V1StGXR8Z5jdHi6B/conversations" \
-H "Authorization: Bearer $NATURALI_TOKEN"
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.