Custom channels
Build a custom messaging channel for Letta Code
Custom channels let you connect a Letta Code agent to a messaging platform that is not bundled with Letta Code. A custom channel runs as a local adapter under ~/.letta/channels/<channel-id>/, receives inbound messages from your platform, routes them to an agent conversation, and lets the agent reply through the MessageChannel tool.
When to use a custom channel
Section titled “When to use a custom channel”Use a custom channel for:
- Community integrations, experiments, and internal workflows
- Platforms that can be managed with local config files
- Channels that can use generic pairing or route files
- Headless deployments where operators are comfortable editing JSON/YAML
Do not use a custom channel to override a bundled channel. Letta Code ignores custom channel IDs that collide with first-party channels such as telegram, slack, and discord. Use a distinct ID like whatsapp-community, telegram-test, or custom-chat.
Directory layout
Section titled “Directory layout”Create a directory for your channel under ~/.letta/channels/:
~/.letta/channels/ whatsapp-community/ channel.json plugin.mjs accounts.json routing.yaml pairing.yaml runtime/ package.json node_modules/The required files are:
| File | Purpose |
|---|---|
channel.json | Registers the channel and declares runtime dependencies |
plugin.mjs | Exports the channel adapter and optional MessageChannel actions |
accounts.json | Stores account records and channel-owned config |
routing.yaml, pairing.yaml, and runtime/ are created or updated by the channel runtime and CLI commands.
Create channel.json
Section titled “Create channel.json”channel.json tells Letta Code how to load your channel adapter:
{ "id": "whatsapp-community", "displayName": "WhatsApp Community", "entry": "./plugin.mjs", "runtimePackages": ["@whiskeysockets/baileys@6.7.18"], "runtimeModules": ["@whiskeysockets/baileys"]}Rules:
idmust match the directory name.idcan contain lowercase letters, numbers, underscores, and hyphens.entryis resolved relative to the channel directory.runtimePackagesare installed into the channel’sruntime/directory byletta channels install <channel-id>.runtimeModulesare resolved from bundled first-party runtimes first, then from the channel’s localruntime/directory.
The adapter is implemented as a local module, but the product concept is still a channel: a communication surface with account config, inbound delivery, and outbound replies.
Create accounts.json
Section titled “Create accounts.json”Each channel account uses the shared channel account envelope. The config object belongs to your channel adapter and may contain secrets or platform-specific settings:
{ "accounts": [ { "channel": "whatsapp-community", "accountId": "main", "displayName": "WhatsApp Community", "enabled": true, "dmPolicy": "pairing", "allowedUsers": [], "config": { "phoneNumber": "+15551234567" }, "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z" } ]}DM policies control who can message your agent:
| Policy | Behavior |
|---|---|
pairing | Unknown users receive a one-time code. An operator approves the code and binds the chat to an agent conversation. |
allowlist | Only users listed in allowedUsers can message the channel. |
open | Any user can message, but a route must exist unless your adapter creates routes through custom logic. |
Start with pairing while testing. Use allowlist or open only when you understand the deployment and audience.
Create plugin.mjs
Section titled “Create plugin.mjs”plugin.mjs exports either channelPlugin or default. This local module creates a channel adapter for each account.
export const channelPlugin = { metadata: { id: "whatsapp-community", displayName: "WhatsApp Community", runtimePackages: ["@whiskeysockets/baileys@6.7.18"], runtimeModules: ["@whiskeysockets/baileys"], },
async createAdapter(account) { return { id: `whatsapp-community:${account.accountId}`, channelId: "whatsapp-community", accountId: account.accountId, name: account.displayName ?? "WhatsApp Community",
async start() { // Connect to the platform SDK and begin receiving messages. },
async stop() { // Close sockets, polling loops, or SDK clients. },
isRunning() { return true; },
async sendMessage(message) { // Send message.text to message.chatId using your platform SDK. return { messageId: crypto.randomUUID() }; },
async sendDirectReply(chatId, text) { await this.sendMessage({ chatId, text }); },
onMessage: undefined, }; },
messageActions: { describeMessageTool() { return { actions: ["send"] }; },
async handleAction({ adapter, request, formatText }) { const formatted = formatText(request.message ?? ""); const result = await adapter.sendMessage({ channel: request.channel, chatId: request.chatId, text: formatted.text, parseMode: formatted.parseMode, threadId: request.threadId, });
return `Message sent to ${request.channel} (message_id: ${result.messageId})`; }, },};Deliver inbound messages
Section titled “Deliver inbound messages”After your adapter connects to the external platform, call adapter.onMessage(message) when a platform message arrives.
A minimal inbound message includes the platform chat, sender, and text:
await adapter.onMessage?.({ channel: "whatsapp-community", accountId: account.accountId, chatId: platformChatId, senderId: platformUserId, senderName: platformDisplayName, text: incomingText, messageId: platformMessageId, timestamp: new Date().toISOString(),});Letta Code then:
- Checks the account’s DM policy and allowlist.
- Looks for an existing route in
routing.yaml. - Creates or updates a pairing in
pairing.yamlwhen approval is required. - Delivers routed messages to the bound agent conversation.
- Exposes
MessageChannelfor conversations with an active route and running adapter.
Install runtime dependencies
Section titled “Install runtime dependencies”If your plugin declares runtime packages, install them before starting the server:
letta channels install whatsapp-communityFor headless deployments, you can install runtimes as the server starts:
letta server --channels whatsapp-community --install-channel-runtimesRuntime dependencies should resolve from the channel runtime directory, not from parent project or development node_modules folders. This keeps custom channels portable across machines.
Start the channel
Section titled “Start the channel”Run Letta Code with your channel enabled:
letta server --channels whatsapp-communityYou can enable multiple channels with a comma-separated list:
letta server --channels telegram,whatsapp-communityPair or route a chat
Section titled “Pair or route a chat”With dmPolicy set to pairing, the first inbound message from an unknown user creates a pairing code. Approve that code from the CLI:
letta channels pair \ --channel whatsapp-community \ --code B5ZR5H \ --agent <your-agent-id> \ --conversation <your-conversation-id>You can also add a route manually:
letta channels route add \ --channel whatsapp-community \ --account-id main \ --chat-id <platform-chat-id> \ --agent <your-agent-id> \ --conversation defaultRoutes are stored in ~/.letta/channels/<channel-id>/routing.yaml.
Test the full loop
Section titled “Test the full loop”Before sharing a channel, test all four legs:
- Channel discovery and import:
letta channels statusshould show your channel and account. - Inbound delivery: a platform message should call
adapter.onMessage(...)and create a route or pairing. - Agent routing: after pairing or routing, the inbound message should appear in the target agent conversation.
- Outbound response: an agent
MessageChannelcall should invokemessageActions.handleAction(...), thenadapter.sendMessage(...).
If messages arrive but replies fail, check that messageActions is present and that handleAction passes the formatted message through your platform SDK.
Security notes
Section titled “Security notes”- Store platform secrets in
accounts.jsonunderconfig; do not hard-code them inplugin.mjs. - Keep
accounts.jsonout of git if it contains real credentials. - For public or shared channels, avoid exposing tool approval prompts to untrusted chats unless you have verified operator routing. Public approval prompts can leak tool inputs and invite forged approvals.
- Prefer
pairingorallowlistwhile developing.