Create your own module
Build a custom BRAD module — file layout, manifest, lifecycle, migrations, and the conventions you have to follow.
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.mdOnly 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 key | Prefix applied | Use for |
|---|---|---|
routes | none | Public-facing pages |
routesAdmin | /admin | Admin-area pages |
routesApi | /api | JSON 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 tohooks.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 underlocales/{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:
weather→WeatherModuleuser-profile→UserProfileModuledebug-tools→DebugToolsModule
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 inschema.prisma, soprisma.weatherLocations.create()won't exist. - Always include both
upanddown. Down isn't called automatically except during uninstall. - Include a
token UUID DEFAULT gen_random_uuid() UNIQUEcolumn on any entity that will appear in a URL — BRAD's URL convention is the first 8 hex chars of a UUID, never the numericid.
What runs when
| Trigger | What happens |
|---|---|
| Every server boot | The 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 Migrations | Manual 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:
| Service | Getter | What it's for |
|---|---|---|
| Prisma | getPrisma() | Database. $queryRaw, $executeRaw, $queryRawUnsafe. |
| Logger | createLogger('Name') | Structured logging — .debug/info/warn/error/success. |
| Files | getFileUploadService() | Upload, query, delete. Returns { id, key, url, ... }. |
| Email / SMS | getCommunicationService() | .sendEmail, .sendSms, .sendSystemEmail(type, to, data). |
| Notifications | getNotificationService() | In-app notifications — fan out + live push to users. |
| Scheduler | getModuleScheduler() | Cron / interval / one-shot tasks scoped to your module. |
| WebSocket | getWebSocketService() | .sendToUser, .sendToRoom, .broadcast. |
| Timeline | getTimelineService() | Audit-log entries on entity timelines. |
| AI agent | getAIAgentService() | LLM chat and tool execution. |
| Payments | getPaymentGatewayService() | Create payment intents, refunds. |
| System config | getSystemConfig() | Company name, logo, settings. |
| License | getLicenseService() | 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— theBaseModuleclass yourmodule.tsextends.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 inonEnable(). - ❌ Using numeric
idin public URLs — use the 8-char short token from atoken UUIDcolumn. The pattern is documented in.github/copilot-instructions.mdunder "Token-Based URL Identifiers". - ❌ Including the
/adminor/apiprefix in route paths — the loader prepends it based on which manifest array (routesAdmin/routesApi) the route is in.