Plugin System
Extend AlgoMLN without touching the engine.
AlgoMLN's plugin system lets you add custom indicators, backtest metrics, DSL keywords, UI panels, scheduled tasks, and market data access — all without modifying the core engine. Plugins are sandboxed, capability-gated, and hot-reloadable from the desktop app.
Plugins live in <app_data>/plugins/<plugin-id>/ and are discovered at startup.
Each plugin directory must contain a plugin.toml manifest and an entry file
(.rhai or .wasm).
Key invariant: Plugins only reach the engine through PluginHost's capability-gated accessors.
If a plugin calls a host function it hasn't declared in its manifest, the call returns
PluginError::PermissionDenied. Logging is the one always-on capability — no declaration needed.
Manifest — plugin.toml
Every plugin requires a plugin.toml in its directory. The manifest declares the plugin's identity, entry point, and required capabilities.
[plugin]
id = "my-indicator-plugin"
name = "My Indicator Plugin"
version = "1.0.0"
entry = "plugin.rhai" # or "plugin.wasm"
[permissions]
capabilities = [
"Indicators", # register custom indicator functions
"Storage", # per-plugin key-value store
]
manifest fields
| Field | Type | Description |
|---|---|---|
id | String | Unique identifier. Used for dedup and per-plugin storage keys. |
name | String | Human-readable name shown in the Plugins screen. |
version | String | Semver version of the plugin. |
entry | String | Filename of the entry file. Extension determines runtime: .rhai → Rhai, .wasm → WASM. |
capabilities | String[] | List of capabilities the plugin needs. See Capabilities. |
Runtimes
Two sandboxed runtimes are supported. The manifest's entry file extension determines which is used.
Rhai Runtime (.rhai)
Rhai is a lightweight, embedded scripting language with Rust-like syntax. AlgoMLN uses a hardened Rhai engine with strict resource limits:
- Operation budget — maximum number of AST operations per call
- Recursion depth limit — prevents stack overflows
- Collection size limit — caps array/map growth
- Module loading disabled — plugins cannot
importother files printoutput is swallowed — use the Logging API instead
// plugin.rhai — minimal Rhai plugin skeleton
fn on_load(host) {
host.log("my-plugin loaded");
// register a custom indicator
host.indicators().register("fast_ema", |candles, period| {
// return a Vec of f64 values
ema(candles, period / 2)
});
}
fn on_enable(host) {
host.log("my-plugin enabled");
}
fn on_disable(host) {
host.log("my-plugin disabled");
}
fn on_unload(host) {
host.log("my-plugin unloaded");
}
WASM Runtime (.wasm)
WASM plugins run in a wasmtime 23 instance with strict isolation:
- Bounded linear memory —
ResourceLimitercaps heap growth - Epoch interruption — a watchdog timer kills runaway execution
- No WASI — plugins have no filesystem or network access; only
algomln::*host functions are linked - Host functions are capability-gated — the WASM module calls
algomln::*functions which enforce the same permission check as Rhai
WASI is intentionally not linked because WasiCtx in wasmtime 23 is Send-only,
which would violate the Plugin: Send + Sync bound required by the plugin registry.
Capabilities
Plugins must declare every capability they need in plugin.toml.
Undeclared capabilities return PermissionDenied at runtime — not at load time.
Logging is always available with no declaration required.
.algomln strategies by name.Indicators
Register custom indicator functions that can be called from .algomln strategies by name:
fn on_load(host) {
// Register "fast_ema" — callable as fast_ema(period) in .algomln
host.indicators().register("fast_ema", |candles, period| {
// Must return Vec, one value per candle
compute_fast_ema(candles, period)
});
}
In a strategy file, after the plugin is enabled:
WHEN fast_ema(10) > ma(50) BUY 5
Analytics
Register custom metrics computed over backtest trade history:
fn on_load(host) {
host.analytics().register_metric("max_drawdown", |trades| {
// trades is a list of completed trade objects
// return a f64 metric value
compute_max_drawdown(trades)
});
}
DSL Extension
Register new keywords that the DSL parser and evaluator can resolve. On disable, all keywords registered by this plugin are unregistered automatically:
fn on_load(host) {
host.dsl_extension().register_keyword("market_open", |candle| {
// Return true/false — the condition value for this keyword
candle.time.hour() == 9 && candle.time.minute() >= 15
});
}
Storage
A simple key-value store backed by a file in the plugin's directory. All keys are automatically namespaced to the plugin's ID — no collision possible across plugins:
fn on_enable(host) {
let store = host.storage();
let visits = store.get("visit_count").unwrap_or(0);
store.set("visit_count", visits + 1);
host.log(`Plugin enabled ${visits + 1} times`);
}
UI Panels
Register a named panel. The React app subscribes to "plugin-ui-message" Tauri events
forwarded by TauriUiApi from a tokio::spawn'd forwarder.
Messages are dispatched in the React app based on the UiMessage variant:
fn on_enable(host) {
let ui = host.ui_panels();
ui.register_panel("my-panel", "My Panel Title");
ui.push_toast("my-plugin", "Plugin enabled!", "success");
ui.stream_data("my-panel", #{ value: 42, label: "custom" });
}
Scheduler
Schedule recurring tasks using cron expressions. All tasks for a plugin are cancelled automatically on disable via cancel_all_for(plugin_id):
fn on_enable(host) {
// Fire every minute at :00 seconds
host.scheduler().cron("0 * * * * *", || {
host.log("tick — every minute");
});
}
Market Data
Read-only access to the same broker client the strategy engine uses:
fn on_enable(host) {
let market = host.market_data();
// Fetch recent OHLCV history
let candles = market.history("NIFTY", "1min", 100);
host.log(`Got ${candles.len()} candles`);
}
Execution
The Execution capability is a reserved no-op stub in v0.1.0.
All calls return immediately without effect. It will be wired to a broker-agnostic execution
facade in Phase 7 (Live Trading).
Event Bus
AlgoMLN has a broadcast event bus that plugins can subscribe to.
The engine publishes three event types from on_candle:
| Event | When it fires |
|---|---|
RuleFired | After TriggerStateMap::should_fire returns true for a rule |
TradeExecuted | Immediately after a successful execution_target.execute call |
CandleProcessed | After the cross-update pass completes for the candle |
Backtests never wire up the event bus. The engine carries an
event_bus: Option<Arc<EventBus>> that is deliberately left as None
during backtest replay. This ensures plugin callbacks cannot affect backtest determinism.
The bus is only attached for paper and live runs.
fn on_enable(host) {
let bus = host.event_bus();
bus.subscribe("RuleFired", |event| {
host.log(`Rule fired: ${event.rule_id}`);
});
bus.subscribe("TradeExecuted", |event| {
host.log(`Trade: ${event.side} ${event.qty} @ ${event.price}`);
});
bus.subscribe("CandleProcessed", |event| {
// fires after every candle during paper/live
});
}
Lifecycle
Plugins implement up to four lifecycle hooks. The runtime dispatches these in order:
| Hook | When called | Required |
|---|---|---|
on_load(host) | Plugin is scanned and loaded at startup. Use to register indicators, analytics, DSL keywords. | Recommended |
on_enable(host) | User enables the plugin from the Plugins screen. Use to set up subscriptions, scheduler tasks. | Optional |
on_disable(host) | User disables the plugin. Resources registered on enable should be cleaned up here. | Optional |
on_unload(host) | Plugin is removed from memory (app shutdown or reload). | Optional |
The registry swaps the real plugin out of its entry under a write lock before awaiting
any lifecycle callback. This prevents deadlock if a plugin re-enters the registry,
since parking_lot guards are !Send and cannot be held across .await.
Resource cleanup is automatic for registered capabilities on disable:
SchedulerApi::cancel_all_for(plugin_id)— all cron tasks cancelledDslExtensionApi::unregister_all_for(plugin_id)— all DSL keywords removed- Indicator and Analytics registrations are attributed with a plugin ID and can be deduped
Management
Plugins can be managed from the desktop app's Plugins screen, or via Tauri IPC commands:
| Command | Description |
|---|---|
list_plugins | Returns all plugins and their current state (Loaded / Enabled / Disabled / Failed) |
enable_plugin(id) | Enable a loaded plugin — calls on_enable |
disable_plugin(id) | Disable an enabled plugin — calls on_disable |
reload_plugins | Unload all plugins and re-scan the plugins directory |
Plugin states:
| State | Meaning |
|---|---|
| Loaded | Plugin manifest parsed and on_load succeeded. Not yet enabled. |
| Enabled | on_enable succeeded. Event bus subscriptions and scheduler tasks are active. |
| Disabled | on_disable called. Plugin is in memory but not active. |
| Failed | Manifest parse error, on_load panic, or runtime error. Plugin is not usable. |