Nimbusv1.0.0

Custom Modules

Guide to building custom Nimbus controller modules with commands, API routes, database access, and event handling.

Nimbus supports a dynamic module system for extending the controller with custom functionality. Modules are standalone JAR files loaded at startup from the modules/ directory. They can register console commands, REST API routes, plugin deployments, doctor checks, event formatters, and versioned database migrations — and interact with core services.

The module API lives in nimbus-module-api and has no dependency on nimbus-core, keeping your module lightweight and decoupled. For typed access to core internals (e.g. EventBus, ServiceManager), add a compileOnly dependency on nimbus-core and use context.service<T>().

First- and third-party modules use exactly the same API. Every shipped module (nimbus-module-perms, nimbus-module-scaling, nimbus-module-players, nimbus-module-punishments, nimbus-module-resourcepacks, nimbus-module-backup, nimbus-module-display) implements NimbusModule against the public ModuleContext — read their source under modules/* as reference.

Quick Start

Project Setup

Create a new Gradle project with the following build.gradle.kts:

build.gradle.kts
plugins {
    kotlin("jvm") version "2.1.10"
}

repositories {
    mavenCentral()
}

dependencies {
    // Module API — the only required dependency
    compileOnly(files("libs/nimbus-module-api.jar"))

    // Needed for Ktor Route type and Exposed Database type
    compileOnly("io.ktor:ktor-server-core:3.1.1")
    compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
    compileOnly("org.jetbrains.exposed:exposed-core:0.57.0")
    compileOnly("org.jetbrains.exposed:exposed-jdbc:0.57.0")
    compileOnly("org.slf4j:slf4j-api:2.0.16")
}

kotlin {
    jvmToolchain(21)
}

tasks.jar {
    // Include resources (ServiceLoader config, module.properties)
    from(sourceSets.main.get().output)
}

If you are developing inside the Nimbus monorepo, replace the file dependency with a project reference:

build.gradle.kts (monorepo)
compileOnly(project(":nimbus-module-api"))

To access core types directly (e.g. EventBus, ServiceRegistry), add a compile-only dependency on nimbus-core:

build.gradle.kts (core access)
compileOnly(project(":nimbus-core"))

Minimal Module

MyModule.kt
package dev.example.mymodule

import dev.nimbuspowered.nimbus.module.ModuleContext
import dev.nimbuspowered.nimbus.module.NimbusModule

class MyModule : NimbusModule {
    override val id = "my-module"
    override val name = "My Module"
    override val version = "1.0.0"
    override val description = "A minimal example module"

    override suspend fun init(context: ModuleContext) {
        // Register commands, routes, initialize state
    }

    override suspend fun enable() {
        // Called after all modules have been initialized
    }

    override fun disable() {
        // Clean up resources on shutdown
    }
}

Required Resources

Your JAR must include a module.properties resource file:

module.properties — metadata and entry point used by the module loader:

module.properties
id=my-module
name=My Module
description=A minimal example module
default=false
main_class=dev.example.mymodule.MyModule
PropertyRequiredDescription
idYesUnique module identifier
nameYesHuman-readable name
descriptionNoShort description for setup wizard
defaultNoWhether enabled by default in setup wizard
main_classRecommendedFully qualified class name of the NimbusModule implementation
min_nimbus_versionNoMinimum compatible Nimbus version (e.g. 0.4.0)
dependenciesNoComma-separated list of required module IDs
pluginsNoPlugin deployments (DisplayName:fileName:resourcePath)

If main_class is not set, Nimbus falls back to the legacy META-INF/services/dev.nimbuspowered.nimbus.module.NimbusModule ServiceLoader descriptor.

Lifecycle

Modules follow a three-phase lifecycle:

Module Lifecycle
loadAll()     →  init(context)  →  enable()  →  disable()
              ↑                  ↑              ↑
              Per module,        After ALL       Reverse order
              in load order      modules init    on shutdown
  1. LoadModuleManager.loadAll() scans modules/ for JAR files, creates a URLClassLoader per JAR, and discovers NimbusModule implementations via ServiceLoader. Duplicate module IDs are rejected.

  2. Initinit(context) is called on each module in load order. This is where you register commands, routes, create database tables, and set up managers. The ModuleContext provides access to core services.

  3. Enableenable() is called after all modules have been initialized. Use this for cross-module interactions or deferred setup that depends on other modules being ready.

  4. Disabledisable() is called during Nimbus shutdown, in reverse load order. Cancel coroutines, close connections, and release resources here.

ModuleContext API

The ModuleContext is passed to init() and provides access to the Nimbus runtime:

MemberTypeDescription
scopeCoroutineScopeCoroutine scope tied to the Nimbus lifecycle. Launch jobs here.
baseDirPathRoot directory of the Nimbus installation.
templatesDirPathTemplates directory (templates/).
databaseDatabaseExposed database instance for direct SQL access.
moduleConfigDir(id)PathReturns config/modules/<id>/, created if missing.
registerCommand(cmd)Register a console command.
unregisterCommand(name)Unregister a command by name.
registerRoutes(block, auth)Register API routes with the given auth level.
registerPluginDeployment(d)Register a plugin JAR to deploy to services (backend or Velocity — see below).
registerEventFormatter(type, fn)Register a console formatter for module events.
registerCompleter(cmd, fn)Register tab completion for a command.
getService(type)T?Retrieve a core service by class. Returns null if unavailable.
registerService(type, instance)Register a late-initialized service (used by core for ServiceManager).
registerMigrations(list)Register versioned database migrations (see below).
registerDoctorCheck(check)Register a diagnostic check for doctor / GET /api/doctor.

Convenience Extension

The service<T>() reified extension avoids passing Class objects:

ModuleContext Extensions
import dev.nimbuspowered.nimbus.module.service

val eventBus = context.service<EventBus>()!!
val registry = context.service<ServiceRegistry>()!!

Console Commands

Implement ModuleCommand to add commands to the Nimbus console:

HelloCommand.kt
import dev.nimbuspowered.nimbus.module.ModuleCommand

class HelloCommand : ModuleCommand {
    override val name = "hello"
    override val description = "Say hello"
    override val usage = "hello [name]"

    override suspend fun execute(args: List<String>) {
        val target = args.firstOrNull() ?: "World"
        println("Hello, $target!")
    }
}

Register it during init():

Register Command
override suspend fun init(context: ModuleContext) {
    context.registerCommand(HelloCommand())
}

The command is immediately available in the Nimbus console. To remove it later:

Unregister Command
context.unregisterCommand("hello")

If your module has a compileOnly dependency on nimbus-core, you can implement the core Command interface instead of ModuleCommand. Both work with registerCommand().

API Routes

Register HTTP endpoints using the Ktor routing DSL. Routes are mounted on the Nimbus API server.

Route Registration

Route Registration
import dev.nimbuspowered.nimbus.module.AuthLevel

override suspend fun init(context: ModuleContext) {
    context.registerRoutes({
        myModuleRoutes()
    }, AuthLevel.SERVICE)
}

Route Definition

Define routes as an extension function on Route:

Route Definition
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.server.routing.*

fun Route.myModuleRoutes() {
    route("/api/my-module") {

        get {
            call.respond(mapOf("status" to "ok"))
        }

        post("/action") {
            val body = call.receive<ActionRequest>()
            // handle request
            call.respond(mapOf("result" to "done"))
        }
    }
}

Auth Levels

The AuthLevel enum controls authentication for your routes:

LevelDescription
NONEPublic, no authentication required.
SERVICERequires a service-level or admin API token. Default.
ADMINRequires the master admin API token.

Database Access

Modules can use the Exposed ORM to create tables and run queries. The Database instance is shared with Nimbus core.

Define Tables

MyRecords.kt
import org.jetbrains.exposed.sql.Table

object MyRecords : Table("my_module_records") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 128)
    val value = integer("value")
    val createdAt = long("created_at")
    override val primaryKey = PrimaryKey(id)
}

Create Tables

Use DatabaseManager.createTables() during init:

Create Tables
override suspend fun init(context: ModuleContext) {
    val db = context.service<DatabaseManager>()!!
    db.createTables(MyRecords)
}

Run Queries

Use transaction with the database instance:

Run Queries
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

fun insertRecord(name: String, value: Int) {
    transaction(context.database) {
        MyRecords.insert {
            it[MyRecords.name] = name
            it[MyRecords.value] = value
            it[MyRecords.createdAt] = System.currentTimeMillis()
        }
    }
}

Core Services

Use getService() or service<T>() to access core Nimbus components. Available services:

ClassDescription
dev.nimbuspowered.nimbus.event.EventBusPublish and subscribe to events.
dev.nimbuspowered.nimbus.database.DatabaseManagerDatabase operations, table creation.
dev.nimbuspowered.nimbus.service.ServiceRegistryQuery running services, get by name or group.
dev.nimbuspowered.nimbus.service.ServiceManagerStart/stop services programmatically. Available after initial boot (lazy access in background loops).
dev.nimbuspowered.nimbus.group.GroupManagerAccess server group configurations.
dev.nimbuspowered.nimbus.config.NimbusConfigRead the main Nimbus configuration.

Accessing core types requires compileOnly(project(":nimbus-core")) in your build. Without it, use getService() with the fully qualified class name via reflection.

Example using EventBus:

EventBus Subscription
import dev.nimbuspowered.nimbus.event.EventBus
import dev.nimbuspowered.nimbus.event.NimbusEvent
import dev.nimbuspowered.nimbus.module.service

override suspend fun init(context: ModuleContext) {
    val eventBus = context.service<EventBus>()!!

    eventBus.on<NimbusEvent.ServiceReady> { event ->
        println("Service ready: ${event.serviceName} (group ${event.groupName})")
    }
}

Events are nested inside the sealed NimbusEvent class (not a flat Events object). Lifecycle classes include ServiceStarting, ServiceReady, ServiceDraining, ServiceStopping, ServiceStopped, ServiceCrashed, ServiceRecovered. Use event.serviceName — there is no event.service object. See the full list in the Events reference.

Plugin Deployments

Modules commonly ship a companion server-side plugin — a permissions plugin, a punishments enforcer, a resource-pack applier, etc. Instead of asking operators to drop JARs into their templates by hand, register a PluginDeployment: Nimbus bundles the JAR as a resource inside your module, and ServiceFactory.resolveModulePlugins deploys it to the service's plugin directory on every prepare (with REPLACE_EXISTING, so deleted or modified copies self-heal on next start).

PluginDeployment
import dev.nimbuspowered.nimbus.module.PluginDeployment
import dev.nimbuspowered.nimbus.module.PluginTarget

override suspend fun init(context: ModuleContext) {
    // Paper / Purpur / Pufferfish / Leaf / Folia — the usual backends.
    context.registerPluginDeployment(PluginDeployment(
        resourcePath = "plugins/my-plugin.jar",
        fileName = "my-plugin.jar",
        displayName = "MyPlugin",
        minMinecraftVersion = 17,          // Optional: deploy only on 1.17+
        foliaRequiresPacketEvents = false,
        target = PluginTarget.BACKEND       // Default.
    ))

    // Velocity proxies — different JAR format (uses @Plugin, not plugin.yml).
    context.registerPluginDeployment(PluginDeployment(
        resourcePath = "plugins/my-proxy-plugin.jar",
        fileName = "my-proxy-plugin.jar",
        displayName = "MyProxyPlugin",
        target = PluginTarget.VELOCITY
    ))
}
FieldPurpose
resourcePathPath inside your module JAR (e.g. plugins/my-plugin.jar). Use Gradle resource copying to embed the JAR
fileNameTarget filename in the service's plugins/ directory
displayNameShown in console logs when the plugin is deployed
minMinecraftVersionMinor version gate (e.g. 20 for 1.20+); null means no restriction
foliaRequiresPacketEventsIf true, PacketEvents is auto-deployed alongside this plugin on Folia
targetPluginTarget.BACKEND (default) for Paper-family, PluginTarget.VELOCITY for proxies

From v0.9.0 on, Nimbus never writes module-registered plugins into templates/global/plugins/ or templates/global_proxy/plugins/. Deployment is runtime-only, per service prepare — templates stay fully user-owned. If your module used to depend on the old template-install flow, remove it; the runtime path does the same job with better hygiene (no stale copies left behind when a module is uninstalled).

Database Migrations

Register versioned schema changes via ModuleContext.registerMigrations(list) during init(). Core's MigrationManager applies every registered migration in ascending version order after all modules finish init().

MyV1_Baseline.kt
import dev.nimbuspowered.nimbus.module.Migration
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Transaction

object MyV1_Baseline : Migration {
    override val version = 9000
    override val description = "Create my_module_records baseline table"
    override val baseline = false // true = mark as already applied on existing DBs

    override fun Transaction.migrate() {
        SchemaUtils.create(MyRecords)
    }
}

Then register:

init()
override suspend fun init(context: ModuleContext) {
    context.registerMigrations(listOf(MyV1_Baseline, MyV2_AddIndex))
}

Migration number ranges

Migrations are global — pick a range that doesn't collide with anyone else:

RangeOwner
1999nimbus-core
10001999nimbus-module-perms
20002999nimbus-module-scaling
30003999nimbus-module-players
40004999nimbus-module-display
50005999nimbus-module-punishments
60006999nimbus-module-resourcepacks
70007999nimbus-module-backup
80008999reserved
9000+Third-party modules (pick a band unlikely to collide)

Migrations are one-way: Nimbus doesn't roll back. Always ship additive migrations (new tables, new nullable columns, new indexes). Destructive operations require a follow-up migration; there's no down().

baseline flag

Set baseline = true when you're retrofitting an existing production schema — the migration is marked as applied on existing databases without running. Fresh installs still execute it. Used by core and every shipped module's V1.

Doctor Checks

Register a DoctorCheck to have your module contribute to doctor / GET /api/doctor. Checks must be fast (<1s) and read-only.

MyDoctorCheck.kt
import dev.nimbuspowered.nimbus.module.DoctorCheck
import dev.nimbuspowered.nimbus.module.DoctorFinding
import dev.nimbuspowered.nimbus.module.DoctorLevel

class MyDoctorCheck(private val storageDir: Path) : DoctorCheck {
    override val section = "MyModule"

    override suspend fun run(): List<DoctorFinding> = buildList {
        if (!Files.exists(storageDir)) {
            add(DoctorFinding(
                level = DoctorLevel.FAIL,
                message = "Storage dir missing: $storageDir",
                hint = "Check permissions on the Nimbus data directory."
            ))
        }
    }
}

Register:

init()
context.registerDoctorCheck(MyDoctorCheck(storageDir))

Findings are grouped by section and rendered in the console/API report.

Event Formatters

For module-fired events (NimbusEvent.ModuleEvent("my-module", "SOMETHING", data)), register a formatter so console output looks native:

Event formatter
context.registerEventFormatter("SOMETHING_HAPPENED") { data ->
    "${success("✓")} thing happened to ${BOLD}${data["target"]}${RESET}"
}

The formatter receives the event's data: Map<String, String> and returns an ANSI-formatted string. See dev.nimbuspowered.nimbus.console.ConsoleFormatter for the standard color helpers (if you depend on nimbus-core).

Late-Initialized Services

Some core services (notably ServiceManager) are created after modules init(). Access them lazily:

Late binding
override suspend fun init(context: ModuleContext) {
    // Schedule a background loop that uses ServiceManager once available
    context.scope.launch {
        delay(10_000)
        val sm = context.service<ServiceManager>() ?: return@launch
        // … use sm …
    }
}

Core calls ModuleContext.registerService(ServiceManager::class.java, ...) after service-layer wiring so that subsequent service<ServiceManager>() calls resolve. Modules can use the same mechanism to expose their own manager classes (e.g. PunishmentManager, BackupManager, ResourcePackManager all call registerService() so the dashboard-side code can query cached state).

Building and Deploying

  1. Build your module JAR:

    Terminal
    ./gradlew jar

    If you use com.github.johnrengelman.shadow, exclude core runtime dependencies so your fat JAR doesn't bundle Ktor, Exposed, kotlinx, or SLF4J — Nimbus core already provides them. Bundling duplicates causes LinkageError at module load.

    build.gradle.kts (shadowJar)
    tasks.shadowJar {
        dependencies {
            exclude(dependency("io.ktor:.*"))
            exclude(dependency("org.jetbrains.kotlinx:.*"))
            exclude(dependency("org.jetbrains.exposed:.*"))
            exclude(dependency("org.slf4j:.*"))
            exclude(dependency("ch.qos.logback:.*"))
        }
    }

    Only shadow your own third-party transitive dependencies (domain-specific libraries Nimbus doesn't ship).

  2. Copy the JAR to the Nimbus modules/ directory:

    Terminal
    cp build/libs/my-module.jar /path/to/nimbus/modules/
  3. Restart Nimbus. The module is loaded automatically at startup.

Module load/enable status is logged to the console:

Output
[INFO] Found 1 module JAR(s) in modules/
[INFO] Loaded module: My Module v1.0.0 (my-module)
[INFO] Initialized module: My Module

Complete Example

A module that tracks service uptime with a console command, REST endpoint, and database table.

UptimeModule.kt

UptimeModule.kt
package dev.example.uptime

import dev.nimbuspowered.nimbus.database.DatabaseManager
import dev.nimbuspowered.nimbus.event.EventBus
import dev.nimbuspowered.nimbus.event.NimbusEvent
import dev.nimbuspowered.nimbus.module.*

class UptimeModule : NimbusModule {
    override val id = "uptime"
    override val name = "Uptime Tracker"
    override val version = "1.0.0"
    override val description = "Tracks service start/stop times"

    private lateinit var context: ModuleContext

    override suspend fun init(ctx: ModuleContext) {
        context = ctx

        // Create database table
        val db = ctx.service<DatabaseManager>()!!
        db.createTables(UptimeRecords)

        // Listen for service events
        val eventBus = ctx.service<EventBus>()!!
        eventBus.on<NimbusEvent.ServiceReady> { event ->
            UptimeStore.recordStart(ctx.database, event.serviceName)
        }
        eventBus.on<NimbusEvent.ServiceStopped> { event ->
            UptimeStore.recordStop(ctx.database, event.serviceName)
        }

        // Register console command
        ctx.registerCommand(UptimeCommand(ctx.database))

        // Register API route
        ctx.registerRoutes({
            uptimeRoutes(ctx.database)
        }, AuthLevel.SERVICE)
    }

    override suspend fun enable() {}

    override fun disable() {}
}

UptimeRecords.kt

UptimeRecords.kt
package dev.example.uptime

import org.jetbrains.exposed.sql.Table

object UptimeRecords : Table("uptime_records") {
    val id = integer("id").autoIncrement()
    val serviceName = varchar("service_name", 64)
    val startedAt = long("started_at")
    val stoppedAt = long("stopped_at").nullable()
    override val primaryKey = PrimaryKey(id)
}

UptimeStore.kt

UptimeStore.kt
package dev.example.uptime

import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object UptimeStore {
    fun recordStart(db: Database, name: String) {
        transaction(db) {
            UptimeRecords.insert {
                it[serviceName] = name
                it[startedAt] = System.currentTimeMillis()
            }
        }
    }

    fun recordStop(db: Database, name: String) {
        transaction(db) {
            UptimeRecords.update({
                (UptimeRecords.serviceName eq name) and UptimeRecords.stoppedAt.isNull()
            }) {
                it[stoppedAt] = System.currentTimeMillis()
            }
        }
    }

    fun getAll(db: Database): List<UptimeEntry> = transaction(db) {
        UptimeRecords.selectAll().map {
            UptimeEntry(
                it[UptimeRecords.serviceName],
                it[UptimeRecords.startedAt],
                it[UptimeRecords.stoppedAt]
            )
        }
    }
}

data class UptimeEntry(val serviceName: String, val startedAt: Long, val stoppedAt: Long?)

UptimeCommand.kt

UptimeCommand.kt
package dev.example.uptime

import dev.nimbuspowered.nimbus.module.ModuleCommand
import org.jetbrains.exposed.sql.Database

class UptimeCommand(private val db: Database) : ModuleCommand {
    override val name = "uptime"
    override val description = "Show service uptime records"
    override val usage = "uptime [service]"

    override suspend fun execute(args: List<String>) {
        val records = UptimeStore.getAll(db)
        val filtered = if (args.isNotEmpty()) {
            records.filter { it.serviceName == args[0] }
        } else {
            records
        }

        if (filtered.isEmpty()) {
            println("No uptime records found.")
            return
        }

        for (record in filtered) {
            val duration = (record.stoppedAt ?: System.currentTimeMillis()) - record.startedAt
            val minutes = duration / 60_000
            val status = if (record.stoppedAt == null) "running" else "stopped"
            println("${record.serviceName}: ${minutes}m ($status)")
        }
    }
}

UptimeRoutes.kt

UptimeRoutes.kt
package dev.example.uptime

import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.Database

fun Route.uptimeRoutes(db: Database) {
    route("/api/uptime") {
        get {
            val records = UptimeStore.getAll(db)
            call.respond(records.map { mapOf(
                "service" to it.serviceName,
                "startedAt" to it.startedAt,
                "stoppedAt" to it.stoppedAt
            )})
        }
    }
}

Resource Files

src/main/resources/module.properties

module.properties
id=uptime
name=Uptime Tracker
description=Tracks service start/stop times
default=false
main_class=dev.example.uptime.UptimeModule

Project Structure

Directory Structure
my-uptime-module/
├── build.gradle.kts
└── src/main/
    ├── kotlin/dev/example/uptime/
    │   ├── UptimeModule.kt
    │   ├── UptimeRecords.kt
    │   ├── UptimeStore.kt
    │   ├── UptimeCommand.kt
    │   └── UptimeRoutes.kt
    └── resources/
        └── module.properties

Advanced Extension Points

Beyond commands, routes, migrations, and doctor checks, modules can plug into a couple of service-level hooks. These are opt-in — the default behaviour doesn't change when nothing's registered.

LocalServiceHandleFactory

Swap out the default ProcessHandle with something else — a container, a VM, a remote sandbox. The Docker module is the reference implementation.

interface LocalServiceHandleFactory {
    fun isAvailable(): Boolean
    suspend fun create(
        service: Service,
        workDir: Path,
        command: List<String>,
        env: Map<String, String>,
        dockerConfig: DockerServiceConfig,
        readyPattern: Regex?
    ): ServiceHandle

    /** Reattach to surviving instances on controller restart. */
    fun recover(): Map<String, ServiceHandle> = emptyMap()
}

Register via context.registerService(LocalServiceHandleFactory::class.java, impl). ServiceManager.startLocalService calls isAvailable() on every start and falls back to a bare process if the factory is down — so a temporarily offline backend doesn't wedge the controller.

ServiceMemorySource

Add a smarter memory reader alongside the default /proc/<pid>/status reader. Useful when you know something the default reader doesn't — e.g. the Docker module returns cgroup memory (the full container RSS) via docker stats, which catches sidecar processes the plain JVM PID would miss.

interface ServiceMemorySource {
    fun readRssMb(service: Service): Long?
}

Register via ServiceMemoryResolver.registerSource(source). The resolver queries every registered source in order and takes the first non-null result before falling back to /proc. Return null when the source doesn't apply to a given service — do not return 0L just because you don't know.

ServiceHandle.kind

Every ServiceHandle exposes a kind: String property (default "process"). Override it to tag your handle — the dashboard reads backedBy on /api/services to render badges, and core uses it for nothing else. Keep the value short and lowercase (docker, podman, firecracker).