Skip to content

Plugin Development Guide

Nox is designed to be extensible. Any developer can create new library functions that are available to .nox scripts, with the same performance and safety guarantees as the built-in standard library.

Nox uses a 3-tier plugin architecture that provides multiple ways to extend the runtime from compiled built-ins to native shared libraries to pure Nox script imports.

TierNameHow It WorksDistributionPerformance
Tier 0Built-inCompiled into the binary (Kotlin annotations)Part of the runtimeFastest (inlined)
Tier 1External PluginLoaded via C ABI (dlopen / LoadLibrary).so / .dylib / .dllNear-native
Tier 2Script ImportLoaded as Nox source (import "file.nox" as ns).nox filesInterpreted

Tier 0 functions are compiled directly into the Nox binary. They use Kotlin annotations for a clean developer experience and MethodHandle-based linking for near-direct-call performance.

@NoxModule(namespace = "math_ext")
object MathExtension {
@NoxFunction(name = "hypot")
@JvmStatic
fun hypot(a: Double, b: Double): Double =
kotlin.math.sqrt(a * a + b * b)
@NoxFunction(name = "clamp")
@JvmStatic
fun clamp(value: Double, min: Double, max: Double): Double =
value.coerceIn(min, max)
}
@tool:name "geometry_tool"
@tool:description "Calculates distances using extended math."
main(double x, double y) {
double distance = math_ext.hypot(x, y);
double clamped = math_ext.clamp(distance, 0.0, 100.0);
return `Distance: ${distance}, Clamped: ${clamped}`;
}

Marks a class as a Nox plugin module.

@NoxModule(namespace = "my_namespace")
object MyPlugin { ... }
AttributeTypeRequiredDescription
namespaceStringYesThe namespace prefix used in NSL (e.g., my_namespace.func())

Rules:

  • All @NoxFunction methods must be annotated with @JvmStatic
  • The namespace must be unique across all loaded modules

Marks a method as a callable NSL function.

@NoxFunction(name = "my_func")
@JvmStatic
fun myFunc(arg1: ParamType1, arg2: ParamType2): ReturnType { ... }
AttributeTypeRequiredDescription
nameStringYesThe function name used in NSL (e.g., namespace.name())

You can use the @NoxGeneric annotation to define generic parameters (like T) for a function. This is especially useful for collections. You can then reference these placeholders in the @NoxType and @NoxTypeMethod annotations.

At compile-time, the compiler resolves the generic types based on the arguments or target object, and produces a mangled SCALL name (e.g. push!int). At run-time, the VM parses this mangled name and automatically generates a strongly-typed NoxNativeFunc adapter specific to those types.

@NoxModule(namespace = "_ArrayMethods")
object ArrayMethods {
@NoxGeneric(["T"])
@NoxTypeMethod(targetType = "T[]", name = "push")
@JvmStatic
fun push(
arr: Any?,
@NoxType("T") item: Any?,
) {
val list = arr as? MutableList<Any?> ?: throw NullPointerException("null array")
list.add(item)
}
@NoxGeneric(["T"])
@NoxTypeMethod(targetType = "T[]", name = "pop")
@NoxType("T")
@JvmStatic
fun pop(arr: Any?): Any? {
val list = arr as? MutableList<Any?> ?: throw NullPointerException("null array")
return list.removeAt(list.size - 1)
}
}

Rules for Generics:

  • The @NoxGeneric annotation declares the placeholder names.
  • Use @NoxType("T") on parameters and functions to specify that their type is tied to the generic T.
  • In @NoxTypeMethod, the targetType can include placeholders like "T[]".
  • The backing Kotlin type for generic variables must be Any?, as it could represent either a primitive (pMem value like Long or Double) or a reference (rMem value like String). The VM Linker handles boxing and unboxing correctly when generating the call adapter.

The linker automatically maps between NSL types and Kotlin/JVM types:

NSL TypeKotlin TypeVM Storage
intLong or IntpMem
doubleDoublepMem (as raw long bits)
booleanBooleanpMem (as 0/1)
stringStringrMem
jsonNoxObjectrMem
json (array)NoxArrayrMem
Any arrayList<*>rMem

The same mapping applies for return types. The linker generates code to place the return value in the correct register bank.

Plugin parameters are required by default. To make a parameter optional, annotate it with @NoxDefault and provide a literal default value. This follows the same rule as user-defined functions: optional parameters must come after all required parameters.

@NoxModule(namespace = "Json")
object JsonModule {
@NoxFunction(name = "stringify")
@JvmStatic
fun stringify(
@NoxType("json") value: Any?,
@NoxDefault("true") pretty: Boolean = true,
): String = NoxJsonWriter(prettyPrint = pretty).write(value)
}

NSL usage:

json data = Json.parse("{\"name\": \"Alice\"}");
string pretty = Json.stringify(data); // pretty-printed (default)
string compact = Json.stringify(data, false); // compact

How it works: The VM has no concept of default parameters. The compiler handles defaults entirely, when a call omits an optional argument, the compiler injects the default literal into the bytecode at the call site. The linker and SCALL instruction remain unchanged.

Supported literal forms:

LiteralExampleNSL Type
"true" / "false"@NoxDefault("true")boolean
Integer@NoxDefault("42")int
Decimal@NoxDefault("3.14")double
Quoted string@NoxDefault("\"hello\"")string
"null"@NoxDefault("null")any reference type

Rules:

  • Optional parameters must come after all required parameters (validated at registration time)
  • The Kotlin parameter should also declare a matching default (= true, = 42, etc.) so the function works correctly when called directly from Kotlin tests

Plugins that need to interact with the sandbox (check permissions, charge gas, access metadata) can accept a RuntimeContext as their first parameter.

@NoxModule(namespace = "secure_io")
object SecureIO {
@NoxFunction(name = "log")
@JvmStatic
fun log(ctx: RuntimeContext, message: String) {
val response = ctx.requestPermission(
PermissionRequest.Plugin(
category = "log",
action = "write",
details = mapOf("message" to message)
)
)
when (response) {
is PermissionResponse.Granted -> println("[Nox] $message")
is PermissionResponse.Denied ->
throw SecurityException("Permission denied: log.write. ${response.reason}")
}
}
@NoxFunction(name = "quota_check")
@JvmStatic
fun quotaCheck(ctx: RuntimeContext): Int =
ctx.remainingInstructions
}

Automatic Injection: The linker detects when the first parameter is RuntimeContext and injects the Sandbox’s context object. The NSL caller does not pass this argument:

// NSL code. NOTE: RuntimeContext is invisible to the script
secure_io.log("Hello from the sandbox!");
int remaining = secure_io.quota_check();

All exceptions thrown by plugin code are automatically contained by the VM:

@NoxFunction(name = "divide")
@JvmStatic
fun divide(a: Double, b: Double): Double {
if (b == 0.0) throw ArithmeticException("Division by zero")
return a / b
}

The VM wraps every SCALL in a try-catch(Throwable):

  1. The JVM exception is caught
  2. It’s converted into a Nox-internal exception
  3. The exception is routed through the Exception Table
  4. If uncaught, it propagates to the NSL try-catch or terminates the program

Guarantee: A plugin bug cannot crash the Host.

In addition to namespace-scoped functions, you can register methods that are bound to a specific NSL type. These appear as method calls on values of that type.

@NoxModule(namespace = "Integer")
object IntegerMethods {
@NoxTypeMethod(targetType = "int", name = "toDouble")
@JvmStatic
fun toDouble(value: Long): Double = value.toDouble()
@NoxTypeMethod(targetType = "int", name = "toString")
@JvmStatic
fun toString(value: Long): String = value.toString()
@NoxTypeMethod(targetType = "int", name = "getNumOfDigits")
@JvmStatic
fun getNumOfDigits(value: Long): Long {
if (value == 0L) return 1L
return kotlin.math.floor(kotlin.math.log10(kotlin.math.abs(value.toDouble()))).toLong() + 1
}
}

NSL usage:

int x = 42;
double d = x.toDouble(); // 42.0
string s = x.toString(); // "42"
int digits = x.getNumOfDigits(); // 2

When you compile your plugin project using the nox-ksp Gradle plugin, the KSP processor automatically generates the adapter code and creates a META-INF/services/nox.plugin.PluginRegistryProvider file in your resulting .jar.

On startup, Nox calls LibraryRegistry.createDefault(), which simply executes:

val providers = ServiceLoader.load(PluginRegistryProvider::class.java)
for (provider in providers) provider.registerAll(registry)

This means no manual registration is required. Any plugin .jar dropped into the classpath is instantly and safely discovered without classpath collisions.

To build your own plugin, you create a standard Kotlin project.

1. Set up your build.gradle.kts:

plugins {
kotlin("jvm") version "2.1.0"
id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}
dependencies {
// Add the Nox core API
implementation("nox:core:1.0.0")
// Add the Nox KSP processor
ksp("nox:nox-ksp:1.0.0")
}

2. Write your plugin code: Create your functions and annotate them with @NoxModule and @NoxFunction.

3. Build your plugin: Run ./gradlew build. The compiler will generate the GeneratedRegistry and META-INF files automatically.

4. Distribute and Embed: Take your resulting my-plugin-1.0.0.jar and distribute it. To use it in a host application, simply add it to the Java classpath when running Nox:

Terminal window
java -cp "nox.jar:my-plugin-1.0.0.jar" nox.cli.NoxCliKt script.nox

Because of the ServiceLoader integration, Nox will immediately recognize your module and its functions will be available to .nox scripts!

@NoxModule(namespace = "text")
object TextUtils {
@NoxFunction(name = "reverse")
@JvmStatic
fun reverse(input: String): String =
input.reversed()
@NoxFunction(name = "repeat")
@JvmStatic
fun repeat(input: String, times: Long): String =
input.repeat(times.toInt())
@NoxFunction(name = "word_count")
@JvmStatic
fun wordCount(input: String?): Long {
if (input.isNullOrBlank()) return 0L
return input.trim().split("\\s+".toRegex()).size.toLong()
}
@NoxFunction(name = "truncate")
@JvmStatic
fun truncate(input: String, maxLength: Long): String {
if (input.length <= maxLength) return input
return input.substring(0, maxLength.toInt() - 3) + "..."
}
}

When running as a GraalVM Native Image binary, Tier 0 annotation scanning is unavailable. Tier 1 (external) plugins are shared libraries (.so, .dylib, .dll) loaded at runtime via dlopen / LoadLibrary and called through a C ABI bridge.

Every Tier 1 plugin exports a C-compatible interface:

// plugin_contract.h is provided by the Nox SDK
typedef struct {
const char* name; // Function name visible to NSL
int param_count; // Number of parameters
int param_types[]; // Type tags (INT=0, DOUBLE=1, BOOL=2, STRING=3, JSON=4)
int return_type; // Type tag for return value
void* func_ptr; // Pointer to the native implementation
} NoxPluginFunc;
typedef struct {
const char* namespace; // NSL namespace
int func_count;
NoxPluginFunc* functions;
} NoxPluginManifest;
// Every plugin must export this symbol
NoxPluginManifest* nox_plugin_init();
#include "nox_plugin.h"
// Note: By default, the first value passed to any C function is the opaque `void* noxruntime` context pointer.
static double hypot_impl(void* noxruntime, double a, double b) {
return sqrt(a * a + b * b);
}
static NoxPluginFunc functions[] = {
{ "hypot", 2, {DOUBLE, DOUBLE}, DOUBLE, (void*)hypot_impl }
};
static NoxPluginManifest manifest = {
.namespace = "math_ext",
.func_count = 1,
.functions = functions
};
NoxPluginManifest* nox_plugin_init() {
return &manifest;
}
Terminal window
# Place plugins in the runtime's plugin directory
$ nox run --plugin-dir ./plugins script.nox
# Or specify individual plugins
$ nox run --plugin ./plugins/libmath_ext.so script.nox
FeatureJVM Mode (Tier 0)External Plugin Mode (Tier 1)
DiscoveryClasspath scanningdlopen + symbol lookup
Call overheadInlined by JITC ABI call (~5ns)
Type safetyCompile-timeManifest-validated at load
PlatformAny JVMPlatform-specific binary

Tier 2 is the simplest extension mechanism: import another .nox file and use its functions under a user-chosen namespace.

import "utils/math_helpers.nox" as mh;
main(double x, double y) {
double dist = mh.calculateDistance(x, y, 0.0, 0.0);
return `Distance: ${dist}`;
}
import "path/to/file.nox" as namespace;

The namespace is mandatory and must be explicitly chosen by the developer. It must not clash with Tier 0 built-in namespaces (Math, File, Http, etc.) or Tier 1 external plugin namespaces.

ElementImported?
FunctionsAccessible as namespace.funcName()
Type definitionsAccessible as namespace.TypeName
Global variablesPrivate to the imported module (module-scoped)
main()Not exported (allows standalone testing of library files)
@tool headersIgnored when importing

Import paths are resolved relative to the importing file’s directory. Circular imports are a compile-time error.

Imported functions are compiled into the same CompiledProgram, they become regular CALL targets (not SCALL). Each module’s globals get a reserved segment in the flat global memory array. See Code Generation, ModuleMeta for details.

DoDon’t
Use RuntimeContext for permission checksAccess the file system directly
Keep functions statelessStore mutable state in globals
Throw exceptions for invalid inputCall System.exit() or exitProcess()
Return immutable results when possibleReturn references to internal mutable state
Keep operations bounded in timeStart new threads
Document permission requirementsAssume permissions are granted