Skip to content
Letta Code Letta Code Letta Docs
Sign up

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.

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.

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:

FilePurpose
channel.jsonRegisters the channel and declares runtime dependencies
plugin.mjsExports the channel adapter and optional MessageChannel actions
accounts.jsonStores account records and channel-owned config

routing.yaml, pairing.yaml, and runtime/ are created or updated by the channel runtime and CLI commands.

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:

  • id must match the directory name.
  • id can contain lowercase letters, numbers, underscores, and hyphens.
  • entry is resolved relative to the channel directory.
  • runtimePackages are installed into the channel’s runtime/ directory by letta channels install <channel-id>.
  • runtimeModules are resolved from bundled first-party runtimes first, then from the channel’s local runtime/ 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.

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:

PolicyBehavior
pairingUnknown users receive a one-time code. An operator approves the code and binds the chat to an agent conversation.
allowlistOnly users listed in allowedUsers can message the channel.
openAny 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.

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})`;
},
},
};

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:

  1. Checks the account’s DM policy and allowlist.
  2. Looks for an existing route in routing.yaml.
  3. Creates or updates a pairing in pairing.yaml when approval is required.
  4. Delivers routed messages to the bound agent conversation.
  5. Exposes MessageChannel for conversations with an active route and running adapter.

If your plugin declares runtime packages, install them before starting the server:

letta channels install whatsapp-community

For headless deployments, you can install runtimes as the server starts:

letta server --channels whatsapp-community --install-channel-runtimes

Runtime dependencies should resolve from the channel runtime directory, not from parent project or development node_modules folders. This keeps custom channels portable across machines.

Run Letta Code with your channel enabled:

letta server --channels whatsapp-community

You can enable multiple channels with a comma-separated list:

letta server --channels telegram,whatsapp-community

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 default

Routes are stored in ~/.letta/channels/<channel-id>/routing.yaml.

Before sharing a channel, test all four legs:

  1. Channel discovery and import: letta channels status should show your channel and account.
  2. Inbound delivery: a platform message should call adapter.onMessage(...) and create a route or pairing.
  3. Agent routing: after pairing or routing, the inbound message should appear in the target agent conversation.
  4. Outbound response: an agent MessageChannel call should invoke messageActions.handleAction(...), then adapter.sendMessage(...).

If messages arrive but replies fail, check that messageActions is present and that handleAction passes the formatted message through your platform SDK.

  • Store platform secrets in accounts.json under config; do not hard-code them in plugin.mjs.
  • Keep accounts.json out 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 pairing or allowlist while developing.