---
title: Discord Reference
description: Discord channel options, setup, the Gateway relay, session grouping, command hooks, delivery, human-in-the-loop, sign-ins, and proactive sessions.
url: "https://eve.dev/docs/channels/discord/reference"
docs_index: /llms.txt
type: reference
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

This page documents every part of `discordChannel` from `eve/channels/discord`. For setup and common tasks, start with [Discord](/docs/channels/discord).

The Discord channel lets people reach your agent from Discord slash commands, buttons, selects, and modals. It lives in `agent/channels/discord.ts`. To also answer DMs, mentions, and thread replies, add the experimental [Gateway relay](#reply-to-dms-and-mentions-experimental).

Discord requires an answer within three seconds, so the channel acknowledges each command at once and runs the agent in the background. [Vercel Connect](/docs/guides/auth-and-route-protection) holds the bot token and checks Discord's signature before it forwards a request to eve.

## Set up Discord

Run the guided setup from your eve project:

```bash
pnpm eve add channel/discord
```

Setup asks whether people reach the agent through **DMs, mentions, and threads** or **a slash command**. It guides you through creating a Discord application and bot, then:

- signs you in to Vercel and links or creates a project;
- validates the bot token;
- creates a Vercel Connect client with `/eve/v1/discord` as its trigger destination;
- sets the application's Interactions Endpoint URL, which buttons such as approvals and sign-ins need in both modes;
- for DMs, mentions, and threads, writes the [Gateway relay](#run-the-relay-as-a-vercel-daemon) in `daemon/` and registers it in `vercel.json` in the project's first function region;
- for DMs, mentions, and threads, turns on the Message Content intent when Discord allows it;
- for a slash command, registers it with a required `message` option and an optional `file` attachment option;
- writes the channel file with Connect-managed credentials; and
- prints the bot installation URL.

The slash command defaults to `/ask` with the description **Ask the eve agent**, and you can edit both during setup. Discord global commands can take up to an hour to appear. A bot that answers messages can also take slash commands, as in [Configure Discord manually](#configure-discord-manually).

In a non-interactive run, answer `discord-messages` with `"gateway"` for DMs, mentions, and threads, or `"commands"` for a slash command. `--yes` picks the slash command, because the relay is experimental and runs as an always-on Vercel Daemon. If your project configures Vercel in `vercel.ts`, setup leaves it alone and prints the `daemons` entry to add.

The generated channel looks like this:

```ts title="agent/channels/discord.ts"
import { connectDiscordCredentials } from "@vercel/connect/eve";
import { discordChannel } from "eve/channels/discord";

export default discordChannel({
  // Connect supplies the application ID and bot token.
  credentials: connectDiscordCredentials("discord/my-agent"),
  // Setup adds `gateway: {}` when the agent answers DMs and mentions.
});
```

The route is `POST /eve/v1/discord` by default. Forwarded requests from Connect are verified with a Vercel OIDC token from the same project.

### Configure Discord manually

Without the guided setup, register a command with Discord's API or the Developer Portal. The channel reads the prompt from a string option named `message`, and an attachment option lets people send a file:

```bash
curl -X PUT "https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands" \
  -H "Authorization: Bot $DISCORD_BOT_TOKEN" -H "Content-Type: application/json" \
  -d '[{"name":"ask","description":"Ask the eve agent","type":1,
    "options":[{"name":"message","description":"What should the agent do?","type":3,"required":true},
      {"name":"file","description":"A file for the agent to read","type":11,"required":false}]}]'
```

Then set the public `https://…/eve/v1/discord` route as the application's Interactions Endpoint URL. Pass `credentials: { applicationId, botToken, publicKey }` to `discordChannel`, or set `DISCORD_APPLICATION_ID`, `DISCORD_BOT_TOKEN`, and `DISCORD_PUBLIC_KEY`.

## Dispatch

`onCommand(ctx, interaction)` decides whether a command runs the agent, and as whom. Return `{ auth }` to run it, optionally with `title` to set the run title, or `null` to ignore the command. Omit `onCommand` to run as the Discord user who sent the command.

The model sees a `<discord_context>` block with `user_id` and the receiving application's `application_id`.

Event handlers receive `(event, ctx)`, with Discord API helpers on `ctx.channel.discord`:

```ts
import { discordChannel } from "eve/channels/discord";

export default discordChannel({
  onCommand: (ctx, interaction) => ({
    auth: {
      principalId: interaction.user.id,
      principalType: "user",
      authenticator: "discord",
      attributes: { channel_id: interaction.channelId, guild_id: interaction.guildId ?? "" },
    },
  }),
  events: {
    "content.completed"(event, ctx) {
      if (event.data.phase !== "reply" || typeof event.data.value !== "string") return;
      return ctx.channel.discord.post(event.data.value);
    },
  },
});
```

## Delivery

The first reply replaces the command's deferred "thinking" response, and later replies post as follow-ups. If Discord rejects the command's interaction token, the bot posts a normal channel message instead. Text over Discord's 2000-character limit is split across messages. Replies mention nobody by default (`allowed_mentions: { parse: [] }`).

With a bot token, the bot shows typing when a turn starts and when it calls a tool. In custom handlers, call `ctx.channel.discord.startTyping()`.

## Human-in-the-loop (HITL)

Approvals and questions render as Discord components. Confirmations and options become buttons, `display: "select"` becomes a string select, and free-text input becomes a button that opens a modal. The session resumes when the user responds. Discord caps a select option's value at 100 characters, so a `display: "select"` request with a longer option `id` fails to post with an error.

A press, select, or modal submission answers as the Discord user who made it, with the same `defaultDiscordAuth` identity their commands get. Approvals are requester-only by default, so only the person whose command asked can approve.

`onAnswers(ctx, interaction)` decides who an answer comes from. Return `{ auth }` to accept the answer, or `null` (or throw) to drop it and keep the request open. `ctx.defaultAuth` is the presser's `defaultDiscordAuth`. It identifies the presser but doesn't authorize them.

If you customize `onCommand`, eve drops every press and submission, with a warning, until you also set `onAnswers`. Otherwise requester-only approvals would refuse the requester, and people your `onCommand` turns away could answer. Give both hooks the same mapping and checks:

```ts
import { discordChannel } from "eve/channels/discord";

// One mapping for commands and answers, so requester-only approvals match.
const auth = (interaction: { user: { id: string } }) => ({
  auth: {
    principalId: `user:${interaction.user.id}`,
    principalType: "user" as const,
    authenticator: "discord",
    attributes: {},
  },
});

export default discordChannel({
  onCommand: (_ctx, interaction) => auth(interaction),
  onAnswers: (_ctx, interaction) => auth(interaction),
});
```

## Connection sign-ins

When a connection needs the person to sign in, eve posts the sign-in to the conversation:

- **In a DM**, the message has a link button to the sign-in page and any confirmation code.
- **In a server**, the shared message says who has to sign in and has a **Sign in** button. eve identifies whoever presses it through `onAnswers`, as for approval presses. The person who has to sign in gets an ephemeral message with the link and code. Anyone else gets an ephemeral note that they can't open it, so no other member can save their account under the requester's identity.

When the sign-in completes or is cancelled, eve edits the message and removes its button.

If `onCommand` or `gateway.onMessage` returns custom identities, set `onAnswers` to the same mapping. Otherwise eve can't match a press to the person who has to sign in, and nobody can open the sign-in in a server.

## Proactive sessions

To start a session without an incoming command, call `to(discord, target).send(message, { auth })` from a schedule `run` handler, or `ctx.to(discord, target).send(message, { auth })` from another channel. The target is `{ channelId, conversationId?, initialMessage? }`. Both need a bot token from Connect or `DISCORD_BOT_TOKEN`.

## Attachments

Files sent through a command's attachment options (type `11`) reach the agent as files. eve downloads them without credentials from Discord's signed CDN URLs (`cdn.discordapp.com` or `media.discordapp.net`). A file over 25 MB, or one whose download fails, reaches the agent as a short note. Each command starts its own session, so a file is available only in that session.

## Options

`discordChannel(config)` takes these options. All are optional.

| Option        | Default                                                                 | Description                                                                                                                                |
| ------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `credentials` | `DISCORD_APPLICATION_ID`, `DISCORD_BOT_TOKEN`, and `DISCORD_PUBLIC_KEY` | From `connectDiscordCredentials(...)`, or `{ applicationId, botToken, publicKey, webhookVerifier }`.                                       |
| `route`       | `/eve/v1/discord`                                                       | Interactions endpoint path.                                                                                                                |
| `api`         | `https://discord.com/api/v10` and the global `fetch`                    | `{ apiBaseUrl, fetch }` for Discord REST calls.                                                                                            |
| `turnPolicy`  | `"steer"`                                                               | `"steer"` or `"queue"` for messages that arrive during a turn.                                                                             |
| `gateway`     | Off                                                                     | Mounts the Gateway route for ordinary messages. See the table below.                                                                       |
| `onCommand`   | Dispatch with `defaultDiscordAuth`                                      | `(ctx, interaction)` hook for slash and application commands.                                                                              |
| `onAnswers`   | The presser, unless `onCommand` is set                                  | `(ctx, interaction)` hook that authorizes button, select, and modal answers. With a custom `onCommand` and no `onAnswers`, eve drops them. |
| `events`      | eve's default handlers                                                  | Per-event handler overrides.                                                                                                               |

`gateway` takes these options:

| Option                | Default                                       | Description                                                                                    |
| --------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `gateway.route`       | `/eve/v1/discord/gateway`                     | Path the relay posts to.                                                                       |
| `gateway.auth`        | `localDev()` and same-environment Vercel OIDC | Auth for relay requests. See [Run the relay outside Vercel](#run-the-relay-outside-vercel).    |
| `gateway.onMessage`   | Answer addressed messages                     | `(ctx, message)` hook. See [Who the bot answers](#who-the-bot-answers).                        |
| `gateway.startThread` | `true`                                        | `false`, `true`, or `{ name, autoArchiveMinutes }`. See [Reply in threads](#reply-in-threads). |

The channel mounts `POST /eve/v1/discord`, plus `POST /eve/v1/discord/gateway` when `gateway` is set.

## Reply to DMs and mentions (experimental)

Discord sends ordinary messages, such as DMs, mentions, and thread replies, only over its [Gateway WebSocket](https://discord.com/developers/docs/events/gateway). Gateway support is experimental, and its API may change. Run `runDiscordGatewayRelay` from `eve/experimental/channels/discord/gateway` in a process that stays connected, and turn on the matching route on the channel. The bot replies to these messages as it replies to commands, including approval buttons and error messages.

```text
Discord Gateway ── WebSocket ──▶ relay (Vercel Daemon) ── POST /eve/v1/discord/gateway ──▶ agent ── REST ──▶ Discord
```

### Enable the Gateway route

Add `gateway` to mount `POST /eve/v1/discord/gateway`:

```ts title="agent/channels/discord.ts"
import { connectDiscordCredentials } from "@vercel/connect/eve";
import { discordChannel } from "eve/channels/discord";

export default discordChannel({
  credentials: connectDiscordCredentials("discord/my-agent"),
  gateway: {},
});
```

Each forwarded message names its author, so whoever passes `gateway.auth` can send messages as any Discord user. The default accepts only `localDev()` and Vercel OIDC tokens from the same project and environment, so a preview deployment can't call production. Keep a custom `gateway.auth` as narrow.

| `gateway` option          | Default                                              | What it does                                                                                                    |
| ------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `auth`                    | `localDev()` and same-environment Vercel OIDC tokens | Accepts a relay that [runs outside Vercel](#run-the-relay-outside-vercel).                                      |
| `route`                   | `/eve/v1/discord/gateway`                            | Path of the relay route.                                                                                        |
| `onMessage(ctx, message)` | Answers [addressed messages](#who-the-bot-answers)   | Returns `{ auth }` to run the agent, or `null` to ignore the message.                                           |
| `startThread`             | `true`                                               | Answers a mention in a text channel in a [new thread](#reply-in-threads). Set `false` to answer in the channel. |

Omit `credentials` to read `DISCORD_APPLICATION_ID` and `DISCORD_BOT_TOKEN` in the channel and `DISCORD_BOT_TOKEN` in the relay.

### Run the relay as a Vercel Daemon

A Vercel Daemon, in beta, keeps the relay connected. `eve add channel/discord` writes these files when you choose to answer messages. To add them yourself, put the relay in its own directory with the same credentials as the agent:

```ts title="daemon/main.ts"
import { connectDiscordCredentials } from "@vercel/connect/eve";
import { runDiscordGatewayRelay } from "eve/experimental/channels/discord/gateway";

await runDiscordGatewayRelay({
  credentials: connectDiscordCredentials("discord/my-agent"),
  messageContent: false,
  url: process.env.EVE_URL,
});
```

```json title="daemon/package.json"
{
  "private": true,
  "type": "module",
  "dependencies": {
    "@vercel/connect": "<same version as your agent>",
    "eve": "<same version as your agent>"
  }
}
```

If you commit a `package-lock.json` in `daemon/`, the image installs from it too:

```dockerfile title="daemon/Dockerfile"
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY main.ts ./
USER node
CMD ["node", "main.ts"]
```

Register the daemon in `vercel.json` with exactly one instance:

```json title="vercel.json"
{
  "daemons": {
    "discord-gateway": {
      "root": "daemon",
      "entrypoint": "Dockerfile",
      "instances": { "iad1": 1 }
    }
  }
}
```

On Vercel, the relay posts to `https://$VERCEL_PROJECT_PRODUCTION_URL`, signed with the project's Vercel OIDC token, so no shared secret is needed. Set `url` to post elsewhere.

The bot needs **View Channels**, **Send Messages**, **Send Messages in Threads**, and **Create Public Threads** to [start threads](#reply-in-threads). This install URL grants all four plus **Read Message History**. Replace `<APPLICATION_ID>`:

```text
https://discord.com/oauth2/authorize?client_id=<APPLICATION_ID>&scope=bot+applications.commands&permissions=309237713920
```

### Run the relay outside Vercel

`runDiscordGatewayRelay` runs on any host that keeps a Node.js 24 process up, such as a VM, container platform, or home server. The agent can stay on Vercel. Off Vercel, change three things:

- **Credentials.** Other hosts have no Vercel OIDC token for Connect, so give the relay the bot token directly. The agent can keep using Connect.
- **URL.** Pass the agent's URL as `url`, because `VERCEL_PROJECT_PRODUCTION_URL` isn't set.
- **Auth.** The route's default auth accepts only your Vercel project's runtime. Use a shared secret: `httpBasic()` from `eve/agents/auth` signs the relay's requests, and `httpBasic()` from `eve/channels/auth` on `gateway.auth` checks them.

Generate a long random secret, for example with `openssl rand -hex 32`. Set it as `DISCORD_RELAY_SECRET` on both the agent's Vercel project and the relay's host. On the agent:

```ts title="agent/channels/discord.ts"
import { connectDiscordCredentials } from "@vercel/connect/eve";
import { httpBasic, localDev } from "eve/channels/auth";
import { discordChannel } from "eve/channels/discord";

export default discordChannel({
  credentials: connectDiscordCredentials("discord/my-agent"),
  gateway: {
    auth: [
      localDev(),
      httpBasic({ username: "discord-relay", password: process.env.DISCORD_RELAY_SECRET! }),
    ],
  },
});
```

On the relay, set `DISCORD_BOT_TOKEN` to the token from **Bot** → **Reset Token** in the Discord Developer Portal, and `EVE_URL` to the agent's production URL:

```ts title="relay/main.ts"
import { httpBasic } from "eve/agents/auth";
import { runDiscordGatewayRelay } from "eve/experimental/channels/discord/gateway";

await runDiscordGatewayRelay({
  // Reads DISCORD_BOT_TOKEN when `credentials` is omitted.
  url: process.env.EVE_URL,
  auth: httpBasic({ username: "discord-relay", password: process.env.DISCORD_RELAY_SECRET! }),
});
```

The relay's `package.json` needs only `eve`. Build the image from the [daemon](#run-the-relay-as-a-vercel-daemon)'s Dockerfile, and run it with a restart policy:

```bash
docker run -d --restart unless-stopped \
  -e DISCORD_BOT_TOKEN -e DISCORD_RELAY_SECRET -e EVE_URL=https://my-agent.vercel.app \
  discord-relay
```

The relay stops on `SIGTERM` after it forwards the messages it has in flight. It exits with an error when Discord refuses the bot token or intents, so fix the configuration before you restart it.

If Deployment Protection covers the relay's URL, create a Protection Bypass for Automation secret. Then pass `auth` a function that returns the Basic `authorization` header and an `x-vercel-protection-bypass` header with that secret.

### Who the bot answers

By default the bot answers DMs, mentions of the bot, replies to its messages, and messages in threads where it already has a conversation. It strips its own mention from the text and never answers bots, including itself. Messages group into sessions this way:

| Message                              | Session                                                                                |
| ------------------------------------ | -------------------------------------------------------------------------------------- |
| A DM                                 | One per DM channel                                                                     |
| A message in a thread                | One per thread                                                                         |
| A mention in a text channel          | A new [thread](#reply-in-threads) started from the message, one session for the thread |
| A reply to the bot in a text channel | The session that posted the replied-to message                                         |

Threads keep each conversation out of the channel, so two people, or one person's unrelated questions, don't share context. A reply to any bot message, including a slash command response, continues that message's session. A reply to a bot message that no live session posted starts a new thread.

Mention the bot's user, not the role with the same name that Discord creates for it. A role mention doesn't address the bot.

#### Message Content intent

Discord sends message text only to bots with the privileged **Message Content** intent, unless the message mentions the bot or is a DM. Without the intent, the bot ignores thread follow-ups that skip the mention, and replies whose author turned off the reply ping, because they arrive empty.

`eve add channel/discord` turns the intent on and sets `messageContent: true` when Discord allows it, for bots in fewer than 100 servers. Otherwise, turn on **Message Content Intent** under **Bot** in the Discord Developer Portal. Then pass `messageContent: true` to `runDiscordGatewayRelay`. If you request the intent before you turn it on, Discord refuses the connection. The relay then stops with an error that names the close code.

#### Choose which messages run the agent

Set `gateway.onMessage(ctx, message)` to choose which messages run the agent, as whom, and in which session. It runs for every message from a person.

- `message.addressed` reports whether the default would answer.
- `message.referencedMessageId` names the message it replies to.
- `defaultDiscordMessageAuth(message)` gives the author the same identity that `defaultDiscordAuth` gives for commands and button presses, so requester-only approvals work across both.

```ts
import { defaultDiscordMessageAuth, discordChannel } from "eve/channels/discord";

export default discordChannel({
  gateway: {
    // Answer only addressed messages in one server.
    onMessage: (_ctx, message) =>
      message.addressed && message.guildId === "123456789012345678"
        ? { auth: defaultDiscordMessageAuth(message) }
        : null,
  },
});
```

Return `conversation` to change how messages group into sessions. Omit it to keep the default.

| `conversation` value | Session                                                   |
| -------------------- | --------------------------------------------------------- |
| A message ID         | The session that posted that message                      |
| `"channel"`          | One session for the whole Discord channel                 |
| Any other string     | Your own grouping: one session per key within the channel |

To give each person one session per channel:

```ts
import { defaultDiscordMessageAuth, discordChannel } from "eve/channels/discord";

export default discordChannel({
  gateway: {
    onMessage: (_ctx, message) =>
      message.addressed
        ? { auth: defaultDiscordMessageAuth(message), conversation: `user:${message.author.id}` }
        : null,
  },
});
```

### Reply in threads

By default, the bot answers a mention in a text channel by starting a public thread from the message. eve names the thread after the message's first line, adds the person, and gives the thread one session. Only messages that would start a new session in a text channel get a thread. DMs, thread messages, and replies that continue a session stay where they are. If Discord refuses to create the thread, for example because the bot lacks **Create Public Threads**, the bot answers in the channel.

Set `gateway.startThread` to `false` to answer in the channel. A mention then starts a session in the channel, and replies to the bot's messages continue it:

```ts title="agent/channels/discord.ts"
import { connectDiscordCredentials } from "@vercel/connect/eve";
import { discordChannel } from "eve/channels/discord";

export default discordChannel({
  credentials: connectDiscordCredentials("discord/my-agent"),
  gateway: { startThread: false },
});
```

To choose per message, return `startThread` from `onMessage`. It overrides `gateway.startThread`. Return `false` for the channel, `true` for a thread, or options that set the thread name and auto-archive delay:

```ts
import { defaultDiscordMessageAuth, discordChannel } from "eve/channels/discord";

export default discordChannel({
  gateway: {
    onMessage: (_ctx, message) =>
      message.addressed
        ? {
            auth: defaultDiscordMessageAuth(message),
            startThread: message.attachments.length > 0 && {
              name: `Review: ${message.attachments[0]!.filename}`,
              autoArchiveMinutes: 1440,
            },
          }
        : null,
  },
});
```

### Forward messages from your own relay

The route accepts any relay that holds a Gateway connection, such as an existing discord.js bot. Forward each Gateway dispatch payload unchanged as the JSON body of a `POST` to `/eve/v1/discord/gateway`:

```json
{
  "op": 0,
  "t": "MESSAGE_CREATE",
  "s": 42,
  "d": { "id": "…", "channel_id": "…", "author": { "…": "…" } }
}
```

The route handles `MESSAGE_CREATE` and acknowledges other dispatch types without acting on them. Authenticate as `gateway.auth` requires, by default with a Vercel OIDC token from the same project and environment. The relay needs the `GUILD_MESSAGES` (`1 << 9`) and `DIRECT_MESSAGES` (`1 << 12`) intents, plus `MESSAGE_CONTENT` (`1 << 15`) for messages that don't mention the bot. Use the response status to decide whether to retry:

| Status | Meaning                                                                                                                                | Relay should                               |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `202`  | A turn started. The body is `{ accepted: true, sessionId }`.                                                                           | Stop.                                      |
| `200`  | Ignored on purpose. The body is `{ accepted: false, reason }`, for example `not_accepted`.                                             | Stop.                                      |
| `400`  | The body is not JSON.                                                                                                                  | Not retry.                                 |
| `401`  | Authentication failed.                                                                                                                 | Fix credentials, not retry.                |
| `413`  | The body is over 1 MB.                                                                                                                 | Not retry.                                 |
| `429`  | Rate limited before the route ran.                                                                                                     | Retry with backoff.                        |
| `5xx`  | Unknown. The route answers `500` before dispatch when `onMessage` throws, but a platform `502` or `504` can come after a turn started. | Not retry, unless your relay deduplicates. |

eve doesn't deduplicate messages. A retry after a turn started, for example because the response was lost, makes the agent answer twice. `runDiscordGatewayRelay` retries only a `429`, or a connection that failed before the request reached eve.

### Run the relay locally

Pull a development OIDC token with `vercel env pull .env.local`, and start `eve dev`. Then run the relay against it from another terminal:

```bash
EVE_URL=http://localhost:2000 node --env-file=.env.local daemon/main.ts
```

Scale the production daemon to zero meanwhile. Every connection receives every message, so two relays make the agent answer twice.

### Limitations

- Run one relay per bot. Each connection receives every message. Bots large enough to need Discord's sharding are out of scope.
- Messages sent while no relay is connected are lost, and Discord doesn't replay them. On a deploy, the old relay disconnects at `SIGTERM` while the new daemon boots, usually a few seconds. A crash or restart leaves the same gap.
- `runDiscordGatewayRelay` doesn't retry a message that may have started a turn, because a retry could answer twice. It drops and logs the message on a `5xx`, on no acceptance within 30 seconds, or on a connection failure after sending.

## What to read next

- [Channels overview](/docs/channels): what every channel shares, and every built-in channel
- [Chat SDK](/docs/channels/chat-sdk): reach Discord through a Chat SDK adapter, with your own credentials and state store
- [Auth & route protection](/docs/guides/auth-and-route-protection): authenticating inbound traffic

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)