Module Basics

A module is a .js file that exports an async default function receiving the robot instance:

// modules/ping.js
import { Command, createTextResponse } from '@devchitchat/chatopsjs'

export default async function (robot) {
  robot.commands.register(new Command({
    id:          'ping',
    description: 'Check if the bot is alive',
    handler:     async () => createTextResponse('Pong! ๐Ÿ“')
  }))
}

Place this in your ./modules/ directory, then create your robot:

const robot = await Robot.create({ directory: './modules' })
// ping command is now registered automatically
๐Ÿ’ก
Module Loading Order

Modules are loaded in alphabetical order by file path. Name files with a numeric prefix (e.g. 01-auth.js, 02-deploys.js) to control load order when it matters.

The Command Class

Every command is a Command instance with a canonical ID:

new Command({
  id:          'tickets.create',     // canonical ID โ€” must be unique
  description: 'Open a new ticket', // shown in help
  aliases:     ['ticket new', 'tc'], // alternative invocations
  args: {
    title:    { type: 'string', required: true },
    priority: { type: 'string', required: false }
  },
  permissions: ['tickets:write'],    // required grants
  confirm: {
    mode:    'yes-no',
    message: 'Create a ticket? (yes/no)'
  },
  handler: async (ctx) => { /* ... */ }
})

Command ID Conventions

Use dot-notation for namespaced IDs โ€” it keeps related commands grouped in help output and avoids collisions between modules:

PatternExample IDs
resource.actiontickets.create, tickets.close
service.actiondeploy.trigger, deploy.rollback
shortping, help, status

Arguments

Declare args in the command schema and access them via ctx.args:

robot.commands.register(new Command({
  id: 'deploy',
  args: {
    service:     { type: 'string', required: true  },
    version:     { type: 'string', required: true  },
    environment: { type: 'string', required: false }
  },
  handler: async (ctx) => {
    const { service, version, environment = 'staging' } = ctx.args
    return createTextResponse(
      `Deploying ${service} ${version} to ${environment}โ€ฆ`
    )
  }
}))

// Invocation:
// deploy --service api --version v2.1 --environment production

Permissions

Commands declare required permissions as an array of strings. The framework checks them against envelope.actor.permissions before invoking the handler:

// Declare required permissions on the command
new Command({
  id:          'deploy',
  permissions: ['deploys:write'],
  handler:     async (ctx) => { /* ... */ }
})

// The actor must have 'deploys:write' or the command returns:
// { ok: false, error: { code: 'permission_denied' } }

Permissions are free-form strings โ€” define whatever scheme makes sense for your team:

// Common patterns:
'tickets:read'
'tickets:write'
'deploys:staging'
'deploys:production'
'incidents:ack'
'admin'

Confirmation Flow

Use confirm for commands with side effects that should require explicit approval:

new Command({
  id:      'db.migrate',
  confirm: {
    mode:    'yes-no',
    message: 'โš ๏ธ Run database migration on production? (yes/no)'
  },
  handler: async (ctx) => createTextResponse('Migration started.')
})

When a user runs db.migrate, the adapter holds the pending command and prompts for confirmation. The handler only executes after the user replies yes.

Response Types

Text Response

import { createTextResponse } from '@devchitchat/chatopsjs'

return createTextResponse('Operation complete. ๐Ÿš€')

Structured Message (IR)

The intermediate representation lets you express rich messages that each adapter renders natively:

import { createMessageResponse } from '@devchitchat/chatopsjs'

return createMessageResponse({
  fallbackText: 'Deployment started',
  blocks: [
    {
      type: 'section',
      text: '๐Ÿš€ Deployment started'
    },
    {
      type: 'facts',
      items: [
        { label: 'Service',     value: ctx.args.service  },
        { label: 'Version',     value: ctx.args.version  },
        { label: 'Environment', value: ctx.args.env      },
        { label: 'Triggered by', value: ctx.envelope.actor.id }
      ]
    }
  ]
})

Native Provider Response

When you need full provider-specific control, use the native escape hatch:

import { createNativeResponse } from '@devchitchat/chatopsjs'

// Discord embed example
return createNativeResponse({
  fallbackText: 'Alert acknowledged',
  provider: 'discord',
  payload: {
    embeds: [{
      title:       'โœ… Alert Acknowledged',
      color:       0x00c896,
      description: `Alert **${ctx.args.id}** acked by ${ctx.envelope.actor.id}`,
      timestamp:   new Date().toISOString()
    }]
  }
})

Middleware in Modules

Modules can also register middleware that applies to all commands:

// modules/audit.js
export default async function (robot) {
  robot.use(async (ctx, next) => {
    const start = Date.now()

    // Before: log the invocation
    robot.log('command:start', {
      command:       ctx.command.id,
      actor:         ctx.envelope.actor.id,
      correlationId: ctx.meta.correlationId
    })

    await next()

    // After: log the result
    robot.log('command:done', {
      command:  ctx.command.id,
      duration: Date.now() - start
    })
  })
}

Using Storage

Commands get access to a ctx.storage interface for durable state:

// modules/counter.js
import { Command, createTextResponse } from '@devchitchat/chatopsjs'

export default async function (robot) {
  robot.commands.register(new Command({
    id:          'counter.increment',
    description: 'Increment the shared counter',
    handler: async (ctx) => {
      const current = (await ctx.storage.get('counter')) ?? 0
      const next    = current + 1
      await ctx.storage.set('counter', next)
      return createTextResponse(`Counter: ${next}`)
    }
  }))

  robot.commands.register(new Command({
    id:          'counter.reset',
    description: 'Reset the counter to zero',
    permissions: ['admin'],
    handler: async (ctx) => {
      await ctx.storage.set('counter', 0)
      return createTextResponse('Counter reset.')
    }
  }))
}

Pattern Listeners

For ambient messages that aren't explicit commands, use listeners:

// modules/emoji-reactions.js
export default async function (robot) {
  robot.listeners.register(/๐Ÿš€/, async (envelope, match) => {
    const adapter = robot.adapters.get(envelope.adapter)
    if (adapter) {
      await adapter.send(envelope, { text: '๐Ÿš€ Launch detected!' })
    }
  })
}
โš ๏ธ
Listeners vs. Commands

Prefer commands for anything that performs an action โ€” they're auditable, permissioned, and confirmable. Use listeners only for passive, ambient reactions that don't need access control.

Full Module Example

// modules/incidents.js
import { Command, createTextResponse, createMessageResponse } from '@devchitchat/chatopsjs'

export default async function (robot) {

  robot.commands.register(new Command({
    id:          'incident.declare',
    description: 'Declare a new incident',
    aliases:     ['incident new', 'inc declare'],
    args: {
      severity: { type: 'string', required: true  },  // sev1 | sev2 | sev3
      title:    { type: 'string', required: true  },
      channel:  { type: 'string', required: false }
    },
    permissions: ['incidents:write'],
    confirm: {
      mode:    'yes-no',
      message: '๐Ÿšจ Declare a new incident? This will page on-call. (yes/no)'
    },
    handler: async (ctx) => {
      const { severity, title } = ctx.args
      const id = `INC-${Date.now()}`
      await ctx.storage.set(id, { severity, title, declaredBy: ctx.envelope.actor.id })

      return createMessageResponse({
        fallbackText: `Incident ${id} declared`,
        blocks: [
          { type: 'section', text: `๐Ÿšจ **${id}** โ€” ${title}` },
          { type: 'facts',   items: [
            { label: 'Severity',    value: severity.toUpperCase() },
            { label: 'Declared by', value: ctx.envelope.actor.id },
            { label: 'Status',      value: 'OPEN' }
          ]}
        ]
      })
    }
  }))

  robot.commands.register(new Command({
    id:          'incident.ack',
    description: 'Acknowledge an incident',
    aliases:     ['inc ack'],
    args: { id: { type: 'string', required: true } },
    permissions: ['incidents:write'],
    handler: async (ctx) => {
      const incident = await ctx.storage.get(ctx.args.id)
      if (!incident) return createTextResponse(`No incident found: ${ctx.args.id}`)

      return createTextResponse(
        `โœ… ${ctx.args.id} acknowledged by ${ctx.envelope.actor.id}`
      )
    }
  }))
}