Discord surface
An optional native Discord surface (ADR 0015, ADR 0016) — DMs and @-mentions reach the agent, replies post back. Self-contained: raw Discord Gateway + REST v10 over httpx
websockets(both already core), nodiscord.py. Off until you give it a bot token — when unset the gateway never starts and the outbound tools aren't registered.
Connect it in the app
The quickest path — no files, no env vars:
- Create a bot and copy its token — follow Bot setup below (≈2 minutes in Discord's Developer Portal).
- In the app, open System → Settings → Discord (or the Discord step in first-run setup), paste the Bot token, and optionally your Discord user ID(s) (so only you can talk to it).
- Click Test connection — it verifies the token and shows the bot's name. Turn Enable Discord on, then Save & apply — the gateway connects live, no restart.
The token is stored in the per-agent secrets.yaml (never committed). The DISCORD_BOT_TOKEN / DISCORD_ADMIN_IDS env vars still work as a fallback for Docker/headless deploys.
Two halves:
- Inbound gateway (
surfaces/discord/) — a persistent listener. A Discord DM is conversational, so it invokes the agent as a chat surface with a per-conversationsession_id(the LangGraph thread key) — multi-turn memory persists per Discord conversation. It also publishes adiscord.messagebus event so the console can surface Discord activity. - Outbound tools (
tools/discord_tools.py) —discord_send/discord_read/discord_react, for pushing into channels. See starter tools.
Bot setup
Creating the bot is the one part that happens on Discord's side. It takes about two minutes:
- Discord Developer Portal → New Application → give it a name (this becomes the bot's name).
- Bot tab → Reset Token → Copy — this is the token you paste into the app (System → Settings → Discord, or the setup wizard's Discord step). Treat it like a password; never commit or share it. If you ever leak it, come back here and Reset Token to invalidate the old one.
- Privileged Gateway Intents (same Bot tab) → turn on Message Content Intent. Without it, messages arrive with empty
contentand the agent can't read them — this is the most common "the bot sees nothing" mistake. - Find your own Discord user ID (so you can lock the bot to just you): in Discord, User Settings → Advanced → Developer Mode on, then right-click your name → Copy User ID. Paste it into the app's Admin user ID(s) field. (Leave it blank to let anyone DM the bot — not recommended for a personal assistant.)
- Add the bot somewhere it can talk to you — either:
- Just DM it — DMs work without adding the bot to any server. Simplest.
- Invite it to a server: OAuth2 → URL Generator → scope
bot; permissionsSend Messages,Read Message History,Add Reactions,Create Public Threads→ open the generated URL and pick your server.
- Back in the app, Test connection (confirms the token + shows the bot name), enable Discord, and save. Then DM your bot.
The gateway requests these intents: GUILDS | GUILD_MESSAGES | GUILD_MESSAGE_REACTIONS | DIRECT_MESSAGES | MESSAGE_CONTENT.
Conversation model
- DMs always continue (no mention needed). Channel messages start a conversation on an @-mention; follow-ups in the same channel from the same user continue it within the timeout window.
- The
conversation_idis the agent'ssession_id(surface-taggeddiscord-dm:…/discord-channel-…:…for provenance in traces), so the LangGraph thread stays keyed across turns. - Burst debounce — a rapid run of messages is coalesced into one invocation after a few seconds of silence (reply attaches to the last).
- Slow-response reactions — fast replies leave the channel clean; only when a turn is slow does a 👀 land on the message(s), swapped to ✅ on completion. (DMs never get reactions — the typing indicator is signal enough.)
- Auto-thread — the first reply in a new channel conversation opens a thread (24h auto-archive) so long answers don't clutter the channel.
Configuration
Discord ships as a first-party plugin (plugins/discord/, ADR 0018/0019) — the gateway, the test-discord route, the outbound tools, and this discord config section are all declared by plugins/discord/protoagent.plugin.yaml, not wired into the core server/ package. Disable it entirely with plugins: { disabled: [discord] }, or replace it with your own ingress plugin — no core edit. See Plugins.
The token, admin list, and on/off toggle are set in the app (Settings → Discord, or the setup wizard) and stored in the per-agent config — bot_token in the gitignored secrets.yaml, the rest under the discord: section of langgraph-config.yaml (resolved into plugin_config["discord"] — a plugin-declared section, not a typed config field):
| Field (Settings → Discord) | YAML | Purpose |
|---|---|---|
| Enable Discord | discord.enabled | Master on/off. Reconnects live on save. |
| Bot token | discord.bot_token (→ secrets.yaml) | Required to enable. The whole surface (gateway + tools). |
| Admin user ID(s) | discord.admin_ids | Discord user IDs allowed to talk to the bot; empty ⇒ anyone. Lock it to yourself for a personal assistant. |
The matching env vars are a fallback for Docker/headless deploys (the in-app config takes precedence when set):
| Env var | Default | Purpose |
|---|---|---|
DISCORD_BOT_TOKEN | — | Enables the surface when no in-app token is set. |
DISCORD_ADMIN_IDS | (unset) | CSV of Discord user IDs (overridden by the in-app admin list). |
DISCORD_CHANNEL_CONVERSATION_TIMEOUT_S | 300 | Channel conversation-continuity window. |
DISCORD_DM_CONVERSATION_TIMEOUT_S | 900 | DM conversation-continuity window. |
DISCORD_BURST_DEBOUNCE_S | 3 | Silence before a message burst is flushed. |
DISCORD_SLOW_REACTION_S | 4 | Grace window before the 👀 "still working" reaction. |
DISCORD_RETURN_ADDRESS_PATH | (instance-scoped default) | Override the return-address store location. |
Proactive delivery (return address)
When you DM the agent, it records that DM channel as your return address. Scheduler-fired and proactive turns have no originating caller — so reactive output that lands in the Activity thread (a fired reminder, an inbox now item, a scheduled briefing) is forwarded to your Discord DM. That's what makes "remind me in 30 minutes" actually arrive somewhere.
- Capture is automatic + idempotent on any DM; only DM channels are stored (a guild channel isn't a private inbox). Override the file location with
DISCORD_RETURN_ADDRESS_PATH. - Delivery is opt-in by usage: until you've DM'd the bot once, there's no address and nothing is forwarded. Your live Discord replies aren't affected (they use per-conversation contexts, not the Activity thread — no double-posting).
One bot per agent
Discord's gateway permits one concurrent connection per token. A second listener on the same token evicts the first — so don't share a token across agents; give each its own bot (cf. multiple instances).
Long-window context
Every Discord exchange is logged to a small SQLite turn store (separate from the knowledge DB; DISCORD_LOG_PATH to override). When a conversation has gone cold (the continuity window expired) or the process restarted, the next message is warmed with the last few turns for that (channel, user) — prepended as a <recent_conversation> block — so continuity survives timeouts and restarts. It's best-effort: if the store can't init, the gateway just runs without warming.