Skip to main content

Connect a Discord channel

By the end of this tutorial you will have a Discord bot connected to a naturali agent, with proof — a real message sent from Discord and a real reply read back through the API — that it works end to end.

Four steps:

  1. Create a Discord app and bot on Discord's own developer portal, and add it to a server.
  2. Create an agent to answer as the bot.
  3. Connect the channel with the bot's credentials.
  4. Validate it with a real DM round trip.

Every naturali-side step is one API call, shown for all three clients. The ids in the responses are examples — copy the ones your own calls return.

Prerequisites

  1. A credential. A nat_sk_… API key (or a session JWT from Auth) exported as NATURALI_TOKEN, and your client set up — the CLI, the SDK, or plain curl against https://api.naturali.ai/v1:

    export NATURALI_TOKEN=nat_sk_...
    export NATURALI_API=https://api.naturali.ai/v1 # curl examples only
  2. A project with a working provider in it. That is exactly what Create a provider builds — do it first if you haven't. Arrive here with both ids exported:

    export PROJECT=proj_V1StGXR8Z5jdHi6B
    export PROVIDER=aip_V1StGXR8Z5jdHi6B
    export NATURALI_PROJECT=$PROJECT # lets the CLI omit --project-id
  3. A Discord account with permission to create applications — that's it. Discord's side of the setup is step 1 below.

1. Create a Discord app and bot

This part happens entirely on Discord's site, not naturali's API:

  1. Go to the Discord Developer Portal and click New Application. Name it anything — the name is cosmetic.
  2. Copy the Application ID from the app's General Information page.
  3. Open the Bot tab, click Reset Token, and copy the token. Discord shows it exactly once.
  4. On the same tab, under Privileged Gateway Intents, leave everything off. This tutorial only connects the dms flow — the bot's default mode — which needs no privileged intent. (The mention_threads flow needs MESSAGE_CONTENT; see What's next.)
  5. Add the bot to a server. Open the OAuth2 tab, use the URL Generator, tick the bot scope, then open the URL it builds and choose a server you administer. The DM flow needs no permissions ticked.
Step 5 is what makes the bot reachable

Discord only lets you open a DM with a bot when you share a server with it, so without this step step 4's DM has nowhere to arrive — and nothing appears on naturali's side either, because no message is ever delivered. If you already have a server the bot is in, you are done here.

The bot token is a live credential

Anyone with this token can run your bot. Treat it like any other secret — export it into your shell, don't paste it into a file you might commit. On naturali's side it's write-only: step 3 accepts it once and no later read ever returns it back.

Export both values:

export DISCORD_APPLICATION_ID=1290000000000000009
export DISCORD_BOT_TOKEN=... # from the Bot tab, shown once

2. Create an agent

An agent binds a provider to instructions. A channel-facing agent needs one thing the smoke-test agent from Create a provider didn't need to have: plain-text output. A channel delivers the reply verbatim to a Discord user, so don't reuse an agent with an output_schema — see Do not bind an agent that has an output_schema.

naturali create-agent \
--provider-id "$PROVIDER" \
--name discord-bot \
--instructions "You are a friendly assistant answering Discord messages. Keep replies short."
export AGENT=agent_V1StGXR8Z5jdHi6B

3. Connect the channel

POST /v1/projects/{project_id}/channels takes the Discord application id, the bot token, and a default action — what answers when nothing more specific does. Leave modes unset: dms is on by default, which is exactly the flow this tutorial validates.

naturali create-channel \
--channel discord \
--application-id "$DISCORD_APPLICATION_ID" \
--bot-token "$DISCORD_BOT_TOKEN" \
--default '{ "action": "agent", "agent_id": "'"$AGENT"'" }'
{
"id": "chan_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"channel": "discord",
"status": "active",
"application_id": "1290000000000000009",
"modes": { "dms": true, "mention_threads": false },
"default": {
"action": "agent",
"agent_id": "agent_V1StGXR8Z5jdHi6B",
"text": null,
"repeat": null,
"language": null,
"config": null
},
"has_credential": true,
"created_at": "2026-07-23T00:00:00.000Z",
"updated_at": "2026-07-23T00:00:00.000Z"
}

has_credential: true confirms the token was sealed — and it is the last you'll ever see of it; no later read returns the value itself. status: "active" says the connection was accepted, not that a bot is actually listening yet — that's what step 4 proves.

export CHANNEL=chan_V1StGXR8Z5jdHi6B
Give the worker a few seconds

Discord's own gateway worker holds the socket that receives DMs, and it picks up a new connection on its next poll — not instantly. If your first DM in step 4 gets no reply within a few seconds, wait a moment and send another.

4. Validate it with a real DM

Open Discord and send the bot a direct message — in the Discord client, not through naturali's API. Anything works; for example:

Hello, this is a test.

The bot should reply within a few seconds. That confirms the happy path from the Discord client's side; the rest of this step confirms it from naturali's side too, by reading back what actually got recorded.

GET /v1/projects/{project_id}/channels/{channel_id}/conversations lists the conversations the inbound message created — one per Discord user, keyed by their user id:

naturali list-channel-conversations --channel-id "$CHANNEL"
{
"data": [
{
"id": "conv_V1StGXR8Z5jdHi6B",
"channel_id": "chan_V1StGXR8Z5jdHi6B",
"identifier": "discord:dm:749641174410854402",
"actor_id": "actor_V1StGXR8Z5jdHi6B",
"session_id": "sess_V1StGXR8Z5jdHi6B",
"created_at": "2026-07-23T00:00:05.000Z",
"updated_at": "2026-07-23T00:00:05.000Z"
}
],
"next_cursor": null
}

Seeing a row here already proves the DM reached naturali, resolved to a real actor and session, and didn't error. Copy the id and read the actual exchange with GET /v1/projects/{project_id}/channels/{channel_id}/conversations/{conversation_id}/messages:

naturali list-channel-conversation-messages \
--channel-id "$CHANNEL" \
--conversation-id conv_V1StGXR8Z5jdHi6B
{
"data": [
{
"role": "user",
"content": "Hello, this is a test.",
"position": 0
},
{
"role": "assistant",
"content": "Hi there! How can I help?",
"position": 1
}
],
"total": 2,
"limit": 50,
"offset": 0
}

Two messages, in order, role: "assistant" last — that's the validation: your Discord message reached the agent, the agent's reply reached naturali's record, and (since you read it in your DM) reached back to Discord too. The whole path is proven, not just configured.

That's the value delivered: a Discord bot, connected to a real agent, with a verified round trip.

What's next

  • Let the bot answer @mentions in a server, in a thread shared by everyone who posts in it — see Two flows, chosen with modes. It needs the privileged MESSAGE_CONTENT intent you skipped in step 1, the thread permissions from Permissions the bot needs, and an @mention of the bot user rather than the same-named role — see Mentioning the bot.
  • Restrict who may invoke the bot in a server with an allowlist — see Who may talk to a Discord bot.
  • Answer differently per surface or condition (a different agent in DMs than in threads, a paywall message for unrecognized users) with the channel route table and addresses.
  • Everything a channel can be — kinds, defaults, rotation — is on the Channels module page.