Discordian is a custom Letta Code channel adapter for Discord. It runs a headless Discord bot, forwards accepted Discord messages into Letta Code conversations, and sends agent replies back to Discord through MessageChannel.
Discordian is intended for teams that want Discord support through Letta Code's custom-channel system rather than the bundled native Discord channel.
- Discord guild channels, threads, and direct messages.
- Per-channel trigger policy: mention-only, always-on, or disabled.
- Per-channel conversation placement: reply in the channel or create/use a thread.
- Automatic Letta conversation and route creation for new Discord channels, threads, and DMs.
- Discord-native typing indicators while accepted turns are processing.
- Discord message reactions for lifecycle acknowledgements when enabled.
- Attachment forwarding, including optional voice/audio file handling through the adapter's media path.
- Bot-message controls for integration channels (
respond_to_botsplus optionalallowed_bot_ids).
channel.json # Custom-channel manifest
plugin.ts # Plugin entrypoint source
plugin.mjs # Bundled plugin loaded by Letta Code
adapter.ts # Discord adapter implementation
accounts.example.json # Safe example account config
docs/ # Design notes and feature specs
Runtime/generated files are intentionally ignored:
accounts.json— local account config; may contain Discord and Letta credentials.routing.yaml— route state generated by Letta Code.pairing.yaml— pairing state generated by Letta Code.runtime/— channel-local dependencies installed by Letta Code.*.transcript.txt— local voice transcription artifacts.
- Open the Discord Developer Portal.
- Create an application, then add a bot user.
- Copy the bot token. You will put it in
accounts.jsonasconfig.token. - Enable the Discord intents needed for the surfaces you will use:
- guild/channel/thread messages need the message-content and guild-message intents required by your Discord app configuration;
- direct messages need Direct Messages support/intents.
- Invite the bot to your server with permission to read messages, send messages, create public threads, send messages in threads, attach files, add reactions, and use external emojis if you enable reaction acknowledgements.
- In Discord, enable Developer Mode and copy the Discord IDs for any channels and users you want to configure.
Create or choose the Letta agent that the Discord bot should represent, then copy its agent id. You will put it in accounts.json as config.agent_id.
Clone this repository and make it visible to Letta Code's custom-channel loader:
git clone <REPOSITORY_URL> discordian
cd discordian
mkdir -p ~/.letta/channels
ln -s "$(pwd)" ~/.letta/channels/discordian
cp accounts.example.json accounts.jsonIf you prefer copying instead of symlinking, copy the repository directory to ~/.letta/channels/discordian and run the remaining commands from that directory.
Fill in at least these values:
config.token— your Discord bot token.config.agent_id— the Letta agent id from step 2.config.allowed_users— Discord user ids that may DM the bot whendm_policyisallowlist.config.channels— Discord channel ids and their trigger/conversation behavior.
Keep these top-level fields open unless you intentionally want Letta Code's generic custom-channel filtering:
{
"dmPolicy": "open",
"allowedUsers": []
}Discordian needs a Letta API key because custom channels do not receive the internal route/conversation helper functions that the native Discord channel uses. The key is used only by the listener to create new Letta conversations for newly seen Discord channels, threads, and DMs.
Preferred option for process managers or shells that launch the listener:
export DISCORDIAN_LETTA_API_KEY="<LETTA_API_KEY>"Make sure the variable is exported in the same environment that starts letta server --channels discordian. Letta Cloud/Letta Code agent secrets available to tool calls do not automatically flow through to a separately launched letta server listener or its channel plugin process. If the listener log reports credentialSource":"missing", either export the variable for that process or use the config fallback below.
Local/testing fallback inside accounts.json:
{
"config": {
"discordian_letta_api_key": "<LETTA_API_KEY>"
}
}Do not commit real API keys or Discord bot tokens.
From anywhere, run:
letta channels install discordianConfirm Letta Code can see the channel:
letta channels statusStart Letta Code with Discordian enabled:
letta server --channels discordianLeave that process running. The bot should log in to Discord.
- Send a message in a configured Discord channel. If the channel uses
trigger: "mention", mention the bot. - If the channel uses
conversation: "thread", confirm the bot creates or replies in a thread. - DM the bot from an allowlisted Discord user and confirm it creates a DM conversation route.
- If the first message is rejected or ignored, check:
- Discord intents and bot permissions;
config.agent_id;DISCORDIAN_LETTA_API_KEYorconfig.discordian_letta_api_key;- channel ids under
config.channels; config.dm_policyandconfig.allowed_usersfor DMs.
Run only one listener process for a Discord bot/account. Multiple letta server --channels discordian processes will each receive the same Discord event and can cause duplicate deliveries.
In the Discord Developer Portal, configure the bot with the intents needed for the surfaces you want to use:
- Guild messages / channels / threads: enable the message-content and guild-message intents required by your Discord app configuration.
- Direct messages: enable Direct Messages support/intents for the bot.
The adapter declares the Discord.js gateway intents it needs, including direct messages. Discord-side app settings still need to permit those events.
Discordian keeps Letta Code's generic custom-channel registry open, then applies Discord-aware authorization inside the adapter. In accounts.json, leave the top-level fields open unless you intentionally want generic custom-channel filtering:
{
"dmPolicy": "open",
"allowedUsers": []
}Use nested config fields for Discordian behavior:
{
"accounts": [
{
"channel": "discordian",
"accountId": "main",
"displayName": "Discordian",
"enabled": true,
"dmPolicy": "open",
"allowedUsers": [],
"config": {
"token": "YOUR_DISCORD_BOT_TOKEN",
"agent_id": "YOUR_LETTA_AGENT_ID",
"default_permission_mode": "standard",
"dm_policy": "allowlist",
"allowed_users": ["YOUR_DISCORD_USER_ID"],
"channels": {
"YOUR_DISCORD_CHANNEL_ID": {
"enabled": true,
"trigger": "mention",
"conversation": "thread"
}
}
}
}
]
}See accounts.example.json for a fuller example.
The native Letta Code Discord channel is inside the Letta Code runtime, so it can call internal helper methods that are not part of the public custom-channel plugin API:
ensureDiscordRoute(adapter, msg, config)— resolves an existing Discord route or creates one when the incoming Discord message should start a conversation.createDiscordRoute(config, msg)— builds and persists the route record for the Discord chat/thread.createConversationForAgent(agentId, summary)— calls the Letta client to create a fresh conversation for the configured agent.addRoute(msg.channel, route)— writes the new route into Letta Code's route store.
Discordian runs as an external custom-channel plugin under the channel id discordian. It cannot safely import or call those internal native-channel helpers. Instead, Discordian recreates the same behavior with public surfaces:
- It calls the public Letta conversations API to create a fresh conversation for
config.agent_id. - It writes the corresponding Discordian route to this channel's
routing.yaml. - It forwards the inbound message only after the route exists, so Letta Code's generic custom-channel registry can deliver the turn normally.
That public API call is why the listener needs a Letta API key. Prefer an environment variable exported into the listener process:
export DISCORDIAN_LETTA_API_KEY="<LETTA_API_KEY>"This must be present in the environment of the letta server --channels discordian process itself. Letta Cloud/Letta Code agent secrets that are available to the interactive agent/tool runtime are not automatically inherited by a separately launched listener or custom-channel plugin process. Check the startup log line for credentialSource; it should say DISCORDIAN_LETTA_API_KEY or config.discordian_letta_api_key, not missing.
For quick local testing, or when your process manager cannot inject the variable, you may put the key in nested account config instead:
{
"config": {
"discordian_letta_api_key": "<LETTA_API_KEY>"
}
}The example config intentionally omits this key so credentials are not copied into version control accidentally. Discordian uses LETTA_BASE_URL when set, otherwise https://api.letta.com.
Discordian's nested DM policy defaults to allowlist.
{
"config": {
"dm_policy": "allowlist",
"allowed_users": ["YOUR_DISCORD_USER_ID"]
}
}Set dm_policy to open only if any Discord user who can DM the bot should be allowed to create a Letta conversation route.
Authorized DM chats create their own Letta conversation route automatically before the first DM is forwarded.
Per-channel guild behavior is configured with config.channels. Channel IDs are Discord snowflake IDs; comment and channel_name are optional human-readable metadata.
{
"channels": {
"MENTION_CHANNEL_ID": {
"comment": "Mention-triggered channel",
"channel_name": "#mentions-only",
"enabled": true,
"trigger": "mention",
"conversation": "channel"
},
"AUTO_THREAD_CHANNEL_ID": {
"comment": "Always-on auto-thread channel",
"channel_name": "#assistant",
"enabled": true,
"trigger": "always",
"conversation": "thread"
},
"INTEGRATION_CHANNEL_ID": {
"comment": "Allows a specific integration bot",
"channel_name": "#integration-triage",
"enabled": true,
"trigger": "always",
"conversation": "thread",
"respond_to_bots": true,
"allowed_bot_ids": ["INTEGRATION_BOT_USER_ID"]
}
}
}trigger controls top-level Discord channel messages:
mention— require a mention of the Discordian bot.always— accept authorized messages without a mention.never— disable that channel.
conversation controls where replies go after a message triggers:
channel— keep the agent conversation in the top-level Discord channel.thread— create or use a Discord thread and route the agent conversation there.
Channel resolution uses: exact channel entry → "*" wildcard entry → account/global defaults. channels is an override map, not an allowlist: unlisted channels still use global defaults. Use enabled: false or trigger: "never" to explicitly disable a channel.
Discordian always ignores its own bot user, even if a channel allows bots.
conversation: "channel"creates one Letta conversation per top-level Discord channel.conversation: "thread"creates one Letta conversation per Discord thread.- Existing Discord threads under allowed parent channels get exact thread routes before the first forwarded message.
- Direct messages create one Letta conversation per Discord DM channel.
- New routes never inherit a parent channel conversation,
LETTA_CONVERSATION_ID, or"default"; existing routes keep their stored conversation ids. - Parent-channel thread-starter events for manually-created Discord threads are suppressed so the bot replies only in the thread conversation.
Discordian sends Discord native typing indicators while accepted turns are processing. Typing is enabled by default and targets the same Discord surface as replies (threadId ?? chatId). Discord's API is pulse-based: Discordian sends an immediate sendTyping() pulse, refreshes it while the turn is active, then clears the refresh timer so the last pulse expires naturally.
Optional account config fields:
{
"typing_indicator": true,
"typing_indicator_refresh_ms": 8000,
"typing_indicator_max_ms": 600000
}CamelCase aliases are also accepted: typingIndicator, typingIndicatorRefreshMs, and typingIndicatorMaxMs.
Build the bundled plugin after source changes:
bun build plugin.ts \
--target=node \
--format=esm \
--outfile=plugin.mjs \
--external:discord.js \
--external:./runtime.mjs \
--external:./transcription-stub.mjsValidate the generated bundle:
node --check plugin.mjsDuring live testing, run only one listener process for a Discord bot/account. Multiple letta server --channels discordian processes will each receive the same Discord event and can cause duplicate deliveries.
MIT License. See LICENSE.
Before publishing or accepting contributions:
- Confirm
accounts.json,routing.yaml,pairing.yaml, local logs, and transcript files are not tracked. - Rotate any credentials that were ever committed or pasted into local logs.