The first version of our agent runtime imported discord.js directly in the main orchestrator file. Every message handler, every reply call, every user lookup was Discord-specific. It worked fine — until we started planning iMessage support.
Adding iMessage meant one of two things: fork the orchestrator into Discord and iMessage versions, or rip out all the Discord calls and put something generic in their place. Neither option is fun after the fact.
The Problem
When your orchestrator talks directly to a messaging platform, the platform is the runtime. Discord’s message.reply() is in your command handler. Discord’s client.users.fetch() is in your logging. Discord’s 2000-character message limit is in your response splitter. Every line ties you to one transport.
The day you want to add a second platform — iMessage, Slack, SMS, a web UI — you’re rewriting the orchestrator, not just adding a new file.
Why This Happens
Messaging integrations feel simple enough to inline. discord.js has clean APIs. You wire up a bot, listen for messages, send replies. It’s twenty lines of code. Why abstract it?
Because the abstraction isn’t about Discord being complex. It’s about the orchestrator being reusable. The transport layer is the thinnest part of the system — message in, message out. Everything interesting happens above it. If the orchestrator is coupled to one transport, all that interesting code is locked in.
The Fix
Define a transport interface. Every transport implements the same contract.
The interface:
// What every transport must implement
{
async init(config) // Connect to service
onMessage(callback) // Register handler
async sendMessage(channel, text) // Reply to a channel
async sendDM(userId, text) // Direct message (for scheduled sends)
async getUserInfo(userId) // Display name for logging
shutdown() // Graceful disconnect
}
// The callback signature the orchestrator receives:
// (senderId, text, replyFn, channelMeta)
Discord implementation (simplified):
class DiscordTransport {
async init(config) {
this.client = new Client({ intents: [/* ... */] });
this.allowlist = new Set(config.allowlist);
await this.client.login(config.token);
}
onMessage(callback) {
this.client.on('messageCreate', async (msg) => {
if (!this.allowlist.has(msg.author.id)) return;
const reply = async (text) => {
// Split at 2000 chars — Discord's problem, not the orchestrator's
for (const chunk of splitMessage(text, 2000)) {
await msg.reply(chunk);
}
};
callback(msg.author.id, msg.content, reply, {
type: msg.channel.isDMBased() ? 'dm' : 'guild'
});
});
}
async sendDM(userId, text) {
const user = await this.client.users.fetch(userId);
await user.send(text);
}
shutdown() {
this.client.destroy();
}
}
The orchestrator loads by config:
function loadTransport(config) {
if (config.type === 'discord') {
return new DiscordTransport();
}
if (config.type === 'bluebubbles') {
return new BlueBubblesTransport();
}
throw new Error(`Unknown transport: ${config.type}`);
}
// In config.json:
{ "transport": { "type": "discord" } }
// Change to:
{ "transport": { "type": "bluebubbles" } }
The orchestrator calls transport.onMessage() once at startup. It never imports discord.js. It never knows which wire it’s talking through.
Platform quirks stay in the transport:
Discord splits at 2000 characters. iMessage has no limit but prefers shorter messages. Discord has slash commands. iMessage doesn’t. These differences live inside each transport implementation, not in the orchestrator. The orchestrator sends text; the transport figures out how to deliver it.
Key Takeaway
A transport layer is the simplest interface in your agent runtime — message in, message out. Define it once, implement it per platform, and your orchestrator becomes transport-agnostic. The five minutes you spend writing the interface saves you from rewriting the orchestrator every time you add a new way to talk to your agent.