Create your own module

Build a custom BRAD module — file layout, manifest, lifecycle, migrations, and the conventions you have to follow.

8 min readmodulesdevelopment

A module is a self-contained folder under modules/{slug}/. The platform discovers it automatically — drop a folder in, restart, the module loader picks it up, runs any pending migrations, syncs its permissions, registers its routes, and (if you enable it) calls its onEnable() hook.

The minimum viable module is two files: module.json (the manifest) and module.ts (the lifecycle class). Everything else is opt-in.

The fastest way to learn the conventions is to copy the modules/example/ folder and rename. It implements every extension point — controllers, routes, settings, hooks, migrations, dashboard widgets, profile tabs, AI tools, broadcasts, conferences — and is the canonical reference. The platform itself uses it for testing.

File structure

modules/your-module/
├── module.json              # Manifest (required)
├── module.ts                # Lifecycle class (required)
├── controllers/             # Route handlers
│   └── MainController.ts
├── service.ts               # Optional singleton service
├── hooks.ts                 # Hook handlers (referenced from manifest)
├── tools.ts                 # AI tools — exports getAITools()
├── websocket.ts             # WebSocket room handler
├── migrations/
│   └── 001_create_tables.ts # Auto-run on boot
├── views/
│   ├── pages/               # Inertia React pages (admin/, frontend/)
│   ├── modals/{slug}/modal.tsx
│   ├── providers/{slug}/provider.tsx
│   ├── widgets/dashboard/{slug}/
│   ├── tabs/profile/admin/{slug}/
│   ├── tabs/profile/frontend/{slug}/
│   └── sections/{slug}/     # Page-builder sections
├── locales/
│   └── en/messages.json     # ICU message format
└── README.md

Only module.json and module.ts are required. The rest exist as needed.

1. The manifest (module.json)

{
  "slug": "weather",
  "name": "Weather Module",
  "description": "Shows live weather conditions on the admin dashboard.",
  "version": "1.0.0",
  "author": "Your Name <you@example.com>",
  "tags": ["weather", "dashboard"],
  "icon": "⛅",
  "navbarIcon": "HiCloud",
  "license": "MIT",
  "migrations": {
    "enabled": true,
    "directory": "migrations"
  },
  "permissions": [
    { "name": "weather.view", "description": "View weather data", "category": "Weather" }
  ],
  "routesAdmin": [
    {
      "method": "GET",
      "path": "/weather",
      "handler": "WeatherController.index",
      "middleware": ["auth"],
      "navbar": true,
      "navbarLabel": "Weather"
    }
  ],
  "defaultSettings": {
    "units": "metric",
    "refreshSeconds": 300
  }
}

Route prefixes

The module loader prefixes routes automatically — don't include the prefix in path:

Manifest keyPrefix appliedUse for
routesnonePublic-facing pages
routesAdmin/adminAdmin-area pages
routesApi/apiJSON endpoints

Permissions

Add the slug to permissions[] and the system auto-registers it on boot via PermissionSyncService. Admin-type roles get every permission automatically — you don't need to grant manually. Check in a controller with bouncer.authorize('hasPermission', 'weather.view').

Optional sections

  • dependencies — list other modules that must be installed first. Auction depends on Invoicing this way.
  • hooks[{ event, handler: "ClassName.method" }], wired to hooks.ts.
  • dashboardWidgets, profileTabs, frontendProfileTabs — registered widgets and tabs.
  • seeder — declares a demo-data seeder runnable from the admin UI.
  • clientAssets — auto-init client-side scripts on page load (e.g., trackers).
  • locales — declare which languages are present under locales/{lang}/messages.json.

2. The lifecycle class (module.ts)

import { BaseModule } from '#services/base_module'
import { createLogger } from '#services/logger'
 
const log = createLogger('Weather')
 
export class WeatherModule extends BaseModule {
  constructor() {
    super('weather', 'Weather Module')
  }
 
  async onInstall(): Promise<void> {
    await super.onInstall()
    // Migrations have already run by the time this is called.
    // First-time seeding goes here.
  }
 
  async onEnable(): Promise<void> {
    await super.onEnable()
    // Runs on EVERY boot for enabled modules.
    // Register cron tasks, websocket rooms, external connections here.
    log.info('Weather module enabled')
  }
 
  async onDisable(): Promise<void> {
    await super.onDisable()
    // Mirror anything you started in onEnable — stop cron tasks,
    // close connections. Orphaned timers are a common bug.
  }
 
  async onUninstall(): Promise<void> {
    await super.onUninstall()
    // Optional cleanup. Soft uninstall preserves data by default;
    // call wipeModuleData() if you really want it gone.
  }
 
  getDefaultSettings(): Record<string, any> {
    return { units: 'metric', refreshSeconds: 300 }
  }
}

:::warning Naming convention The exported class must be {CapitalizedSlug}Module:

  • weatherWeatherModule
  • user-profileUserProfileModule
  • debug-toolsDebugToolsModule

The loader looks for that exact export. Different name → module fails to load with no obvious error. :::

3. Migrations

Module tables are not declared in prisma/schema.prisma. They live in your module's migrations/ directory and apply via the module migration system.

import type { PrismaClient } from '@prisma/client'
 
export async function up(prisma: PrismaClient): Promise<void> {
  await prisma.$executeRaw`
    CREATE TABLE IF NOT EXISTS weather_locations (
      id SERIAL PRIMARY KEY,
      token UUID DEFAULT gen_random_uuid() UNIQUE,
      name VARCHAR(255) NOT NULL,
      latitude DOUBLE PRECISION NOT NULL,
      longitude DOUBLE PRECISION NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
  `
  await prisma.$executeRaw`
    CREATE INDEX IF NOT EXISTS idx_weather_locations_token
    ON weather_locations(token)
  `
}
 
export async function down(prisma: PrismaClient): Promise<void> {
  await prisma.$executeRaw`DROP TABLE IF EXISTS weather_locations CASCADE`
}

Rules

  • File names: 001_descriptive_name.ts, 002_..., 003_... — three-digit prefix, run in order.
  • Use raw SQL via prisma.$executeRaw — your tables aren't in schema.prisma, so prisma.weatherLocations.create() won't exist.
  • Always include both up and down. Down isn't called automatically except during uninstall.
  • Include a token UUID DEFAULT gen_random_uuid() UNIQUE column on any entity that will appear in a URL — BRAD's URL convention is the first 8 hex chars of a UUID, never the numeric id.

What runs when

TriggerWhat happens
Every server bootThe module loader runs any pending migrations for every enabled module. New migration file + restart = applied.
onInstall()A fresh install runs all migrations before calling your hook.
Admin → Modules → Run MigrationsManual trigger for an already-installed module with new pending files.

:::danger Don't apply module migrations by hand Don't use npx prisma migrate or run psql against module tables. The loader is the single source of truth — prisma migrate dev will see every module's tables as drift and try to reset the database. If you ever feel like you need to write a script to apply a module migration, you don't. Drop the file in, restart. :::

4. Routes and controllers

import type { HttpContext } from '@adonisjs/core/http'
import { getPrisma } from '#services/prisma'
 
export default class WeatherController {
  async index({ inertia, bouncer }: HttpContext) {
    await bouncer.authorize('hasPermission', 'weather.view')
 
    const prisma = getPrisma()
    const locations = await prisma.$queryRaw`
      SELECT id, token, name, latitude, longitude
      FROM weather_locations
      ORDER BY name
    `
 
    return inertia.render(
      // Module pages render via this helper — namespace = module slug
      resolveModuleView('weather:/index'),
      { locations },
    )
  }
}

resolveModuleView('weather:/index') looks up modules/weather/views/pages/index.tsx — frontend pages live under views/pages/, admin pages under views/pages/admin/.

5. Reusable core services

Your module can call any of the platform's singleton services. Always import via the #services/ alias and use the getter, never new. The most-used ones:

ServiceGetterWhat it's for
PrismagetPrisma()Database. $queryRaw, $executeRaw, $queryRawUnsafe.
LoggercreateLogger('Name')Structured logging — .debug/info/warn/error/success.
FilesgetFileUploadService()Upload, query, delete. Returns { id, key, url, ... }.
Email / SMSgetCommunicationService().sendEmail, .sendSms, .sendSystemEmail(type, to, data).
NotificationsgetNotificationService()In-app notifications — fan out + live push to users.
SchedulergetModuleScheduler()Cron / interval / one-shot tasks scoped to your module.
WebSocketgetWebSocketService().sendToUser, .sendToRoom, .broadcast.
TimelinegetTimelineService()Audit-log entries on entity timelines.
AI agentgetAIAgentService()LLM chat and tool execution.
PaymentsgetPaymentGatewayService()Create payment intents, refunds.
System configgetSystemConfig()Company name, logo, settings.
LicensegetLicenseService()License verification (read-only — talks to brad.software).

6. Adding your module to the catalog (paid distribution)

If you intend to sell your module on app.brad.software, you'll also need to register it on the headless side so it can be added to licenses. That's a separate doc — for now, anything you build locally is yours to use immediately. The module loader doesn't care whether a slug is in the public catalog.

Where to look in the source

Inside brad-cms:

  • modules/example/ — copy-and-rename starting point.
  • app/services/base_module.ts — the BaseModule class your module.ts extends.
  • app/services/module_loader.ts — what discovers your folder on boot.
  • docs/module-lifecycle.md, docs/module-migrations.md, docs/module-controllers.md, docs/module-client-assets-inertia.md — the canonical developer docs.

Common mistakes to avoid

  • Class name doesn't match {CapitalizedSlug}Module — the loader won't find it.
  • Manually running module migrations with prisma migrate dev — that command treats every module's tables as drift and tries to reset the database. Drop the migration file in, restart, done.
  • Adding module tables to prisma/schema.prisma — module tables stay out of the core schema. They're raw SQL via the migration system.
  • Forgetting to clean up in onDisable() — orphaned cron tasks and websocket subscriptions. Mirror everything you start in onEnable().
  • Using numeric id in public URLs — use the 8-char short token from a token UUID column. The pattern is documented in .github/copilot-instructions.md under "Token-Based URL Identifiers".
  • Including the /admin or /api prefix in route paths — the loader prepends it based on which manifest array (routesAdmin / routesApi) the route is in.