obi-jam pattern intermediate

Make Your Agent Transport a Swappable Layer

We built the Discord integration first because it was fastest to ship. But we built it behind an interface because we knew iMessage was next. That one decision saved us from rewriting the orchestrator twice.

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.

FAQ

How do I build an agent that works on Discord now but can switch to iMessage later?

Define a transport interface with init(), onMessage(), sendMessage(), and getUserInfo() methods. Build your Discord transport as one implementation. Build your iMessage transport (via BlueBubbles or similar) as another. Your orchestrator imports neither directly — it loads the right one from config at startup. Swapping transports is a config change, not a code change.

What methods should an agent transport interface include?

At minimum: init(config) to connect, onMessage(callback) to register the message handler, sendMessage(channel, text) to reply, sendDM(userId, text) for scheduled messages, getUserInfo(userId) for display names, and shutdown() for graceful disconnect. The callback signature should be (senderId, text, replyFn, channelMeta) — giving the orchestrator everything it needs without transport-specific details.

Can I run multiple transports simultaneously for the same agent?

The architecture supports it — you'd initialize two transports and wire both to the same command router. In practice, start with one. The value of the interface isn't running two at once; it's being able to swap without touching the orchestrator.