๐ŸŽฏ
The Adapter Rule

Adapters translate โ€” they don't decide. Keep all business logic in command handlers and modules. An adapter should be swappable without changing a single line of module code.

Anatomy of an Adapter

Every adapter extends the base Adapter class and implements three methods:

MethodResponsibility
start()Bootstrap the provider connection (WebSocket, polling, CLI readline, etc.)
send(envelope, message)Deliver a response to the channel where the command originated
reply(envelope, message)Deliver a response directly to the actor (DM or thread). Defaults to send()

Minimal Adapter

import { Adapter } from '@devchitchat/chatopsjs'

export class MyAdapter extends Adapter {
  constructor(robot, options = {}) {
    super(robot, 'my-adapter')
    this.options = options
  }

  async start() {
    // 1. Connect to provider
    // 2. On each incoming message, build an envelope
    // 3. Call robot.receive(envelope) to dispatch
    this.robot.log('adapter:start', { adapter: this.name })
  }

  async send(envelope, message) {
    // Translate the framework message IR to provider format
    // envelope.channel.id tells you where to deliver it
    const text = message.text ?? ''
    await this.deliver(envelope.channel.id, text)
  }

  async deliver(channelId, text) {
    // Your provider SDK call here
    throw new Error('Not implemented')
  }
}

The Envelope

When a message arrives from your provider, convert it into an envelope and pass it to robot.receive():

// Shape of an inbound envelope
const envelope = {
  adapter:  'my-adapter',        // your adapter name
  type:     'message',
  text:     '!deploy api v2.1', // the raw message text
  actor: {
    id:          'U01ABC',       // provider user ID
    permissions: ['deploys:write']
  },
  channel: {
    id:      'C01XYZ',           // channel / room ID
    guildId: 'G01DEF'            // optional (Discord guilds, Slack workspaces)
  }
  // Add any provider-specific fields you need in handlers
}
โš ๏ธ
Prefix Stripping

If your platform uses a command prefix (e.g. ! or /), strip it from envelope.text before calling robot.receive() โ€” or pass the prefix to normalizeCommandName() during command resolution.

Response Rendering

The framework delivers one of three response shapes to your send() method. Handle all three:

async send(envelope, message) {
  if (message.native?.provider === this.name) {
    // ๐Ÿ”ต Provider-native payload โ€” pass through directly
    await this.client.chat.postMessage({
      channel: envelope.channel.id,
      ...message.native.payload
    })
    return
  }

  if (message.blocks) {
    // ๐ŸŸก Structured IR โ€” render blocks to provider format
    const providerBlocks = this.renderBlocks(message.blocks)
    await this.client.chat.postMessage({
      channel: envelope.channel.id,
      text: message.text,
      blocks: providerBlocks
    })
    return
  }

  // ๐ŸŸข Plain text fallback
  await this.client.chat.postMessage({
    channel: envelope.channel.id,
    text: message.text
  })
}

renderBlocks(blocks) {
  return blocks.map(block => {
    if (block.type === 'section') {
      return { type: 'section', text: { type: 'mrkdwn', text: block.text } }
    }
    if (block.type === 'facts') {
      const fields = block.items.map(i => ({
        type: 'mrkdwn', text: `*${i.label}:* ${i.value}`
      }))
      return { type: 'section', fields }
    }
    return block
  })
}

Slack Adapter Example

A complete Slack adapter using the Bolt SDK:

// adapters/slack/app.js
import { App } from '@slack/bolt'
import { Robot } from '@devchitchat/chatopsjs'
import { SlackAdapter } from './adapter.js'

const app = new App({
  token:         process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  socketMode:    true,
  appToken:      process.env.SLACK_APP_TOKEN
})

const robot = await Robot.create({ directory: './modules' })
const slack = new SlackAdapter(robot, { app, prefix: '!' })
robot.adapters.add(slack)
await slack.start()
await app.start()
// adapters/slack/adapter.js
import { Adapter } from '@devchitchat/chatopsjs'

export class SlackAdapter extends Adapter {
  constructor(robot, { app, prefix = '!' } = {}) {
    super(robot, 'slack')
    this.app    = app
    this.prefix = prefix
  }

  async start() {
    this.app.message(async ({ message, say }) => {
      if (!message.text?.startsWith(this.prefix)) return

      const envelope = this.messageToEnvelope(message)
      const result = await this.robot.receive(envelope)

      if (result.ok && result.response) {
        await this.send(envelope, result.response)
      }
    })
  }

  messageToEnvelope(message) {
    const permissions = this.resolvePermissions(message)

    return {
      adapter: this.name,
      type:    'message',
      text:    message.text.slice(this.prefix.length).trim(),
      actor: {
        id:          message.user,
        permissions
      },
      channel: {
        id: message.channel
      },
      slack: message
    }
  }

  resolvePermissions(message) {
    return []
  }

  async send(envelope, message) {
    const { channel } = envelope.channel

    if (message.native?.provider === 'slack') {
      await this.app.client.chat.postMessage({ channel, ...message.native.payload })
      return
    }

    if (message.blocks) {
      await this.app.client.chat.postMessage({
        channel,
        text:   message.text,
        blocks: this.renderBlocks(message.blocks)
      })
      return
    }

    await this.app.client.chat.postMessage({ channel, text: message.text })
  }

  renderBlocks(blocks) {
    return blocks.flatMap(block => {
      if (block.type === 'section') {
        return [{ type: 'section', text: { type: 'mrkdwn', text: block.text } }]
      }
      if (block.type === 'facts') {
        return [{
          type:   'section',
          fields: block.items.map(i => ({
            type: 'mrkdwn',
            text: `*${i.label}:*\n${i.value}`
          }))
        }]
      }
      return []
    })
  }
}

Discord Adapter Example

The Discord adapter ships as an example in the repository. Here's its core structure:

// examples/discord/adapter.js  (simplified)
import { Adapter } from '@devchitchat/chatopsjs'
import { PermissionFlagsBits } from 'discord.js'

const PERMISSION_MAP = {
  [PermissionFlagsBits.ManageMessages]: 'messages:manage',
  [PermissionFlagsBits.BanMembers]:     'members:ban',
  [PermissionFlagsBits.Administrator]:  'admin'
}

export class DiscordAdapter extends Adapter {
  constructor(robot, { client, prefix = '!' } = {}) {
    super(robot, 'discord')
    this.client = client
    this.prefix = prefix
  }

  async start() {
    this.client.on('messageCreate', async (message) => {
      if (message.author.bot) return
      if (!message.content.startsWith(this.prefix)) return

      const envelope = this.messageToEnvelope(message)
      const result   = await this.robot.receive(envelope)

      if (result.ok && result.response) {
        await this.send(envelope, result.response)
      } else if (!result.ok && result.error) {
        await message.reply(result.error.message ?? 'Unknown error')
      }
    })
  }

  messageToEnvelope(message) {
    const permissions = createDiscordPermissionList(message)
    return {
      adapter: this.name,
      type:    'message',
      text:    message.content.slice(this.prefix.length).trim(),
      actor: {
        id: message.author.id,
        permissions
      },
      channel: {
        id:      message.channelId,
        guildId: message.guildId ?? null
      },
      discord: message
    }
  }

  async send(envelope, message) {
    const channel = await this.client.channels.fetch(envelope.channel.id)

    if (message.native?.provider === 'discord') {
      await channel.send(message.native.payload)
      return
    }

    if (message.blocks) {
      const embeds = this.blocksToEmbeds(message.blocks)
      await channel.send({ content: message.text, embeds })
      return
    }

    await channel.send({ content: message.text })
  }

  blocksToEmbeds(blocks) {
    const embed = { fields: [] }
    for (const block of blocks) {
      if (block.type === 'section') embed.description = block.text
      if (block.type === 'facts') {
        block.items.forEach(i => embed.fields.push({ name: i.label, value: i.value, inline: true }))
      }
    }
    return [embed]
  }
}

function createDiscordPermissionList(message) {
  if (!message.member?.permissions) return []
  return Object.entries(PERMISSION_MAP)
    .filter(([flag]) => message.member.permissions.has(BigInt(flag)))
    .map(([, grant]) => grant)
}

Registering and Starting

const robot  = await Robot.create({ directory: './modules' })
const adapter = new MyAdapter(robot, { /* options */ })

robot.adapters.add(adapter)   // register
await adapter.start()         // bootstrap provider connection
๐Ÿ“ก
Loading adapters from the CLI

The built-in CLI launcher supports loading external adapters with the --adapter flag:

bun run src/cli.js --adapter ./adapters/slack/app.js