Creating Adapters
Adapters are thin translation layers. They normalize provider events into envelopes the framework understands, and translate framework responses back to the provider's format.
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:
| Method | Responsibility |
|---|---|
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
}
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
The built-in CLI launcher supports loading external adapters with the --adapter flag:
bun run src/cli.js --adapter ./adapters/slack/app.js