1. Overview
A Mallard plugin is a small Lua program plus a manifest, distributed as a signed .mallardx archive. Plugins extend the client per-world: they can register triggers, aliases, client commands, timers, GMCP and MXP handlers, keymaps, settings, persistent storage, and one or more dockable panels rendered from sandboxed HTML.
The runtime is mlua (Lua 5.4). Each (world, plugin) pair gets its own VM, started on connect; enabling and disabling a plugin gates event delivery without recreating the VM.
This page covers the on-disk shape of a plugin, the manifest schema, the Lua surface exposed to plugin code, and how to get a plugin onto the marketplace. For end-user docs (the tray, the dock layout, the plugin manager UI), see Mallard — Documentation.
What's new in v0.15.0
v0.15.0 adds one small, purely additive piece of Lua API surface — the aliases option on mud.command — plus several client features that change how your plugin's commands and sends appear to the user. Everything from v0.14.0 keeps working with no edits and nothing to migrate.
- Plugin command aliases.
mud.command(name, fn, opts)now accepts analiasesfield — a single string (aliases = "v") or an array (aliases = { "v", "vlt" }) — so a command can answer to short forms. Aliases are validated like command names (a malformed alias raises at registration), lowercased, and de-duplicated; they dispatch exactly like the command name and are listed in/commands. An alias that collides with an existing command name or alias is dropped quietly (first-wins), and the single letters used by built-in shortcuts are reserved. See §5.3 for details. - Built-in commands got single-letter shortcuts, and
/commandsnow lists built-ins./c,/g,/w,/p,/k,/l,/rnow reach the built-in commands,/commandsshows every command's shortcut in a new column, and a command's detail card lists all of its aliases — including the ones your plugin registers. When picking alias letters, prefer two-plus characters; the built-in letters are taken. - End users can now write inline Lua in rules. A rule can run a Lua snippet as one of its actions, authored in the rule editor with instant syntax validation and executed with the full host API available. This user Lua runs in a dedicated per-world user-script runtime, separate from every plugin VM — it cannot see your plugin's globals, storage, or settings — so there is no new isolation concern for plugin authors. It does mean the Lua API your plugin uses is now end-user-facing documentation too.
- Your sends get their own echo lane. Sends generated by handlers — plugin callbacks, rules, and aliases — are now stamped as a distinct echo source with their own line colour (new
--line-send-echo-fgtheme key), separate from text the user typed. Users can independently show or hide typed-command echoes, handler-generated send echoes, and input echo. Hiding is display-only: echoes are always produced and logged, so don't rely on echo visibility for anything semantic. /plugin installfrom the input line. Users can install a marketplace plugin without opening the GUI: the command stages the plugin and prints its full permission list, and installation proceeds only after/confirm— the permission preview is the consent step. One more reason to keep your manifest's permissions minimal and self-explanatory: they're now read as prose at install time.- Fix: no more stale runtimes after an idle-out reconnect. Reconnecting a world after an idle disconnect previously could leave a plugin attached to a stale runtime; stale runtimes are now detached on reconnect, so callbacks land in the live VM. Nothing to change in your plugin.
The dedicated plugin runtime and the 750 ms per-callback watchdog from v0.12.0 are unchanged, and the same advice still holds: keep handlers short and push long-running work onto timers.
Elsewhere in v0.15.0, end users gain a family of typed settings commands (/global, /world, /plugin, /keymap) with two-stage confirmation for destructive verbs, colour-styled built-in command output that adapts to every theme, keymap-layer activation as a rule action, an unread-line badge when scrolled off the live tail, and a ~45 MB slimmer Linux AppImage (MP3 chimes now decode via mpg123 instead of bundled GStreamer). These are client-side and don't affect the plugin surface.
All of this is backward compatible — there is nothing to migrate, and no breaking change in v0.15.0.
2. File layout
An unpacked plugin is a directory. Only plugin.toml and the file named by entry are required; everything else is convention. Recommended shape:
my-plugin/
├── plugin.toml # manifest (required, at the root)
├── src/
│ ├── main.lua # entry point (path matches manifest.entry)
│ └── helpers.lua # require()'d from main
├── ui/
│ ├── vitals.html # iframe content for a custom panel
│ ├── vitals.js
│ └── vitals.css
└── assets/
├── icon.png
└── sounds/
└── ding.ogg
Lua files are loaded directly off disk. Panel HTML and any sibling assets are served to the panel iframe via the in-process mallard:// scheme — your HTML can use ordinary relative URLs (<img src="../assets/icon.png">) and they resolve into the plugin's unpacked directory.
There is no require path setup. Use ordinary Lua require for sibling modules; the runtime treats the plugin's directory as the package root.
3. Manifest (plugin.toml)
The manifest is TOML. It declares identity, the entry point, the permissions you intend to ask for, your panels, and any user-facing settings. The full schema is the v1 manifest validated at plugin install and load time.
3.1 Core fields
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | ✓ | Stable identifier. Lowercase alphanumerics, dots, hyphens, underscores. Convention: reverse-DNS, e.g. net.discworld.vitals. |
name | string | ✓ | Display name shown in the plugin manager and on tray flyouts. |
version | string | ✓ | SemVer. Bump per release; the marketplace surfaces this on the Updates tab. |
language | string | ✓ | Must be "lua" in v1. |
entry | string | ✓ | Relative path to the Lua file executed at load. No leading /, no .. segments. |
mallard_api_version | string | ✓ | Minimum Mallard plugin-API version your code targets. Parsed as a ^X.Y.Z requirement. |
minimum_app_version | string | ✓ | Lowest Mallard release that satisfies you. Parsed as >=X.Y.Z. |
authors | array<string> | Optional, free-form. | |
license | string | Optional SPDX identifier. |
Minimal manifest:
id = "com.example.hello"
name = "Hello"
version = "0.1.0"
language = "lua"
entry = "src/main.lua"
mallard_api_version = "1.0"
minimum_app_version = "0.13.0"
3.2 Worlds match
If present, restrict the plugin to specific worlds by host:port glob. Without this block, the plugin is offered for every world.
[worlds]
match = [
"discworld.starturtle.net:23",
"*.aardmud.org:*",
]
Patterns are matched against the literal string "{host}:{port}" with glob semantics (*, ?, character classes). A plugin that doesn't match a world is still installable but the user won't see it in that world's tray.
3.3 Permissions
Every capability that can affect the user, the network, or the OS is gated. Declare what you need; on install the user sees a summary and either grants or declines. Calling a gated API without the matching grant raises a Lua error.
| Key | Shape | Effect |
|---|---|---|
sends | bool | Allow mud.send and mud.send_raw. |
gmcp_access | array of glob strings | Allow gmcp.send / gmcp.on for packages whose name matches one of the patterns (e.g. "Char.*", "Room.Info"). |
notifications | bool | Allow ui.notify. |
keychain | bool | Allow the keychain.* calls. |
[permissions]
sends = true
gmcp_access = ["Char.Vitals", "Char.Status", "Room.Info"]
notifications = true
keychain = false
A handful of forward-compatible keys (network, filesystem, clipboard, external_app) parse without error but aren't yet enforced. Declare them so the user-facing summary is accurate; don't rely on them for sandboxing in v1.
3.4 Panels
Each entry under [panels.<id>] becomes a chip in the tray for matching worlds. Three panel kinds are supported:
| kind | What it is | entry |
|---|---|---|
custom | An iframe loading your HTML. Bidirectional RPC with Lua via panel:post / panel:on_message. | required — path to the HTML file |
grid | A built-in grid of declarative cells (bars, indicators, buttons). No HTML to write; you describe cells in Lua. | not allowed |
log | A built-in auto-scrolling text log. You push lines from Lua. | not allowed |
[panels.vitals]
title = "Vitals"
kind = "custom"
entry = "ui/vitals.html"
default_dock = "right"
default_size = { width = 320, height = 240 }
default_dock_after = "output"
popout_allowed = true
[panels.combat]
title = "Combat log"
kind = "log"
max_lines = 500
autoscroll = true
| Field | Notes |
|---|---|
title | Required. Shown on the dockview tab and the tray flyout. The chip's two-letter initial is derived from this. |
default_dock | One of left, right, above, below. Hint for where the panel lands the first time the user docks it. |
default_size | Pixel hint for the panel's initial dimensions. |
default_dock_after | Anchor against the named built-in panel (e.g. "output") instead of the active group. |
popout_allowed | Reserved for the pop-out window feature. |
max_lines / autoscroll | Log-kind only. Default cap is 1000 lines. |
3.5 Settings
Settings declared here appear in the plugin manager as a per-world form under your plugin's Settings disclosure. They're typed and validated against the manifest at save time.
[settings.show_alignment]
type = "bool"
default = false
label = "Show alignment cell"
description = "Adds an alignment readout to the vitals grid."
[settings.regen_rate]
type = "number"
default = 3.0
min = 0.0
max = 10.0
label = "GP regen per round"
[settings.xp_window]
type = "enum"
default = "1h"
label = "XP/hour window"
choices = [
{ value = "5m", label = "5 minutes" },
{ value = "30m", label = "30 minutes" },
{ value = "1h", label = "1 hour" },
]
Supported types are bool, string, number, and enum. Setting keys are lowercase alphanumerics and underscores, 1–64 chars, no leading or trailing _. The prefix mallard. is reserved.
3.6 GMCP advertise
Packages your plugin would like Mallard to advertise to the MUD on connect, on top of the built-in set:
[gmcp]
advertise = ["char.vitals", "room.info"]
Advertising doesn't grant access — you still need gmcp_access in [permissions] to read or send the package.
4. The .mallardx archive
A .mallardx file is a plain ZIP archive containing the plugin directory. Either layout is accepted:
- Flat —
plugin.tomlsits at the root of the zip alongsidesrc/,ui/, etc. - Nested — every entry is under one top-level directory whose name matches the plugin
id.
On install Mallard validates:
plugin.tomlexists at the chosen root.- No entry path contains
..or escapes the root (zip-slip protection). - No symlinks. Directory entries are skipped.
- No single entry exceeds 64 MiB uncompressed.
- The manifest parses and the SemVer constraints are satisfiable.
A marketplace plugin ships with a detached minisign signature alongside the archive: my-plugin-1.0.0.mallardx and my-plugin-1.0.0.mallardx.minisig. The client embeds the marketplace's ed25519 public key and verifies the signature before unpacking. Sideloaded plugins (installed from a local file or dropped into the plugins folder) are not signature-checked; the install dialog surfaces that fact.
5. Lua API
Looking for an exhaustive signature list? The Plugin API reference is generated directly from the Mallard source and lists every namespace, function, signature, and option key, always in sync with the release. This section is the curated guide — the concepts, semantics, and worked examples behind those calls; reach for the reference when you just need to look one up.
All globals listed below are installed by the runtime before your entry file runs. They're already bound to the current world — no need to pass a world handle around. Anything labelled "permission-gated" raises a Lua error if the matching [permissions] grant isn't held.
5.1 mud namespace
Every mud.* function — send/send_raw (newline fan-out, opts.silent, gated by sends), note, play_sound/stop_sounds/sounds, command_prefix, panel, request_restart, and the trigger/styling/timer calls covered below — is listed with full signatures and option tables in the API reference. Two members are read-only data rather than functions, so they're spelled out here:
| Accessor | Returns |
|---|---|
mud.world | Read-only table: { id, name, host, port, character }. |
mud.viewport() | Fresh table { cols, rows } with the output pane's current cell dimensions. |
A typical sounds-picker fragment:
for _, s in ipairs(mud.sounds()) do
print(s.name, s.source) -- "mallard:chime-low", "bundled" / "alert.wav", "user"
end
Bundled sound IDs
Mallard ships with a small catalog of MP3 chimes that are bundled into the binary and addressable as mallard:<id>. They're available everywhere a sound name is accepted — mud.play_sound, the PlaySound trigger/alias action, and any plugin code that emits sound references. Filename collisions are deliberately ignored: a user file literally named mallard:chime-low in the sounds folder does not shadow the bundled chime, and any user file whose basename starts with mallard: is skipped when building the catalog returned by mud.sounds().
mud.play_sound("mallard:ding-dong", { volume = 70 })
The 11 bundled IDs:
mallard:chime-chompmallard:chime-highmallard:chime-lowmallard:chime-vibemallard:clock-chimemallard:ding-ding-dingmallard:ding-ding-highmallard:ding-ding-lowmallard:ding-dongmallard:ding-dong2mallard:tritone-chime
The end-user sound-file dropdown in the trigger/alias editor groups the two sources separately — Bundled and From sounds folder — so users can pick a chime without any filesystem setup.
5.2 Triggers, aliases & the match object
Triggers fire on incoming MUD lines; aliases fire on outgoing input. Both take a Rust-flavoured regex (not Lua patterns) and a callback that receives a match object. (A chain of multiple patterns matched across consecutive lines is covered separately in Chain triggers.)
mud.trigger("^You are slain by (?P<killer>\\w+)\\.", function(m)
mud.note("Killed by " .. m.killer, { fg = "red", bold = true })
end, { priority = 100 })
mud.alias("^gn$", function(m)
mud.send("south\nsouth\neast")
end)
Options accepted by both:
| Key | Meaning |
|---|---|
flags | Regex flags. i case-insensitive, m multiline, s dotall, x verbose. |
priority | Integer; higher runs first within the same world. |
fires_remaining | Stop after N matches. nil for unlimited. |
enabled | Initial enabled state (default true). |
name | Re-registering with the same name replaces the previous handle. Useful on hot reload. |
Both return a handle with :enable(), :disable(), :remove(), and the read-only fields id, kind, enabled.
The match object passed to the callback exposes captures and effect methods:
| Access | Returns |
|---|---|
m[i] | Positional capture i (1-based). Coerced to number when it parses cleanly, otherwise a string. |
m.name | Named capture name (same coercion). |
m.text | The full matched line. For chain triggers, this is the completing (last) row's text. |
m.named | Table of every named capture, keyed by name. Convenient for iteration. |
m.lines | Per-row breakdown for chain triggers. Empty for single-line triggers and aliases. |
m.args | For client-command callbacks: the trimmed rest of the input line. Always a string (empty when the user typed just the command), never nil. Absent on regular triggers/aliases. |
m:raw(k) | Capture k (index or name) as a raw string, no coercion. |
Side-effect methods queue effects that are applied after the callback returns:
| Method | Effect |
|---|---|
m:gag() | Suppress the matched line from output. |
m:style(opts) | Style a capture (or the whole line). See styling. |
m:replace(capture, value) | Replace a capture's text. capture = 0 targets the whole match. value can be a literal string. |
m:send(text, opts?) | Queue a command, same fan-out and opts.silent semantics as mud.send. |
m:note(text, opts?) | Queue a note line. |
5.3 Client commands (mud.command)
Client commands are a name-keyed alternative to regex aliases. The user types the command prefix (default /) followed by the command name and optional arguments; the plugin's callback receives the rest of the line in m.args. They surface in the plugin rules UI as their own row, alongside that plugin's triggers and aliases, and obey the same enabled/disabled toggle.
mud.command("vault", function(m)
if m.args == "" then
mud.send("vault status")
else
mud.send("vault " .. m.args)
end
end)
With the default prefix, the user types /vault or /vault deposit 100. The leading / is always accepted, even if the user has changed their prefix to something else (so a plugin can document /vault without worrying about the user's choice).
When you want a hint to read in the user's own prefix — a mud.note on startup, a panel button label, a usage line — build it from mud.command_prefix() rather than a literal /:
mud.note("Type " .. mud.command_prefix() .. "vault to open the bank.")
It returns the active prefix as a one-character string and is read live, so the hint stays correct if the user changes their prefix later — no reload needed. See mud namespace.
Name validation
Command names must match ^[A-Za-z][A-Za-z0-9_-]*$: ASCII letter first, then letters / digits / - / _. Invalid names raise a Lua error at registration. Lookup is case-insensitive at dispatch time — /Vault, /vault, and /VAULT all reach the same callback — but the case you register at is preserved for display in the rules UI.
Dispatch order & the user's command prefix
When the user submits a line, Mallard inspects the first character:
- If it's the user's configured prefix or
/, Mallard strips it, takes the first whitespace-separated token as the command name, looks it up in the per-world command table, and — if found and enabled — fires the plugin callback withm.argsset to the trimmed rest of the line. - Otherwise — and also if the lookup misses — the original line falls through to the regular regex-alias scan, then to the wire.
Command dispatch happens before the regex alias pass: a registered command always wins over a regex alias that would also match. The prefix itself is configured by the end user in Settings → General → Command prefix; it must be a single printable ASCII character that isn't a letter or digit (so /, +, ,, etc. are valid; a or 5 are not).
First-wins collision
Command names are global across plugins for a given world. If a second plugin tries to register a command that's already owned by another plugin, the registration is dropped and a warning is logged to the Plugin Activity log:
command "vault" already registered by plugin "com.example.bank"; registration dropped
The call still returns a handle, but it's a no-op: :enable(), :disable(), and :remove() do nothing. Prefer descriptive, prefix-able names (vault-deposit over deposit) when there's any chance of a clash. Two registrations from the same plugin with the same name behave identically — the later one is dropped.
Aliases (new in v0.15.0)
A command can declare short forms via the aliases option — a single string or an array of strings:
mud.command("vault", function(m)
mud.send("vault " .. m.args)
end, { aliases = { "v", "vlt" }, description = "Bank vault shortcuts" })
An alias dispatches exactly like the command name — same prefix handling, same case-insensitive lookup, same callback and match object — and shows up alongside the name in /commands. Each alias is validated with the same rule as a command name (a malformed alias raises a Lua error at registration, so you get immediate feedback), lowercased, and de-duplicated with order preserved.
Collision handling mirrors command names: an alias that clashes with an existing command name or alias — from any plugin, or a built-in — is dropped quietly at registration, first-wins. Note that the built-in commands own most single letters (/c, /g, /w, /p, /k, /l, /r, /y, /n), so prefer two-plus-character aliases like vlt — they're far less likely to be silently shadowed.
The match object
The callback receives a standard match object with two notable differences:
m.argsis the trimmed rest of the line — everything after the command name, with leading and trailing whitespace stripped. It's always a string; an empty argument list reads as"", nevernil. Idiomatic check:if m.args == "" then ….m.textis the full submitted line, including the prefix and the command name.- No regex captures are involved, so
m[1]/m.name/m.linesare not populated. Side-effect methods (m:send,m:note) still work;m:gag(),m:style, andm:replaceare no-ops for a command (there's no MUD line to mutate).
Splitting arguments is left to the plugin:
mud.command("cast", function(m)
if m.args == "" then return mud.send("spells") end
local spell, target = m.args:match("^(%S+)%s+(.+)$")
if not spell then
mud.note("usage: /cast <spell> <target>", { fg = "yellow" })
return
end
mud.send(string.format("cast '%s' at %s", spell, target))
end)
Handle
mud.command returns the same kind of handle as triggers and aliases: :enable(), :disable(), :remove(), plus the read-only kind field — which reads "command" here. The enabled/disabled state is persisted per-world in the user's plugin-rule overrides, and the value is restored on next plugin load.
On plugin teardown (disable, uninstall, or a settings-driven restart), all commands the plugin registered are removed from the command table; nothing leaks across reloads.
5.4 Chain (multi-line) triggers
A chain trigger fires when a sequence of patterns matches consecutive (or nearly-consecutive) incoming lines. The callback is invoked once, on the completing line, and receives a match object whose m.lines exposes the per-row breakdown. The same shape is accepted by mud.trigger, mud.style, and mud.replace.
Declaring the chain
Pass an array of two or more sub-patterns as the first argument instead of a string. Two forms are accepted; entries must all be one or all the other.
-- Form A: array of regex strings (the common case).
mud.trigger({
"^Your health: (\\d+)$",
"^Mana low: (\\d+)$",
"^You say: heal me$",
}, function(m)
mud.send("cast 'heal' at self")
end, { within_lines = 10 })
-- Form B: array of tables with per-row mode and flags.
mud.trigger({
{ pattern = "^\\[(\\d+) HP\\]", mode = "regex", flags = "" },
{ pattern = "WARNING: Low Mana", mode = "exact_full_line" },
{ pattern = ".*cast.*", mode = "regex", flags = "i" },
}, function(m)
m:style { fg = "red", bold = true }
end, { within_lines = 7 })
Per-row mode is one of "regex" (default), "exact" (literal substring), or "exact_full_line" (literal whole-line equality). Per-row flags are the same regex flags accepted by single-line triggers. flags at the top level of the chain options is rejected — chain regex flags live on the rows.
Chain options
| Key | Meaning |
|---|---|
within_lines | How many incoming lines the engine will wait for the full sequence to complete. Must be ≥ 1; default is 5. If the sequence doesn't finish in time, the partial match is discarded and matching restarts. |
priority / fires_remaining / enabled / name | Same meanings as on single-line triggers. |
The match object on chain callbacks
The match object exposes a flat view across all rows plus a per-row view:
m.text— the completing (last) row's text.m[i]— the flat 1-based capture index across the whole chain. Earlier-row captures come first, then later-row captures.m.named/m.somename— named captures, also flat across the chain. Within a single chain, a name should only be used in one row.m.lines— array, one entry per row. Each entry hastext(the matched line),captures(1-indexed;captures[1]is the row's overall match,captures[2..]are that row's user captures), andnamed(a key-value table of that row's named captures).
mud.trigger({
"^You see (?P<mob>[A-Z][a-z]+) approach\\.$",
"^(?P<mob>[A-Z][a-z]+) draws a weapon\\.$",
}, function(m)
-- m.lines[1].text → "You see Bandit approach."
-- m.lines[2].text → "Bandit draws a weapon."
-- m.mob → "Bandit" (from row 2; named captures are flat)
mud.note(string.format("[combat] %s engaging", m.lines[2].named.mob),
{ fg = "yellow" })
end, { within_lines = 4 })
Mutating effects on chains
For now, m:gag, m:style, and m:replace only affect the completing row of a chain. Calls that target a capture from an earlier row are silently dropped. This is a deliberate descope; cross-line retroactive edits are tracked separately.
Static mud.style / mud.replace with a chain pattern
The chain shape works for the callback-free mud.style and mud.replace too. The trigger options (within_lines, priority, etc.) go in a third argument:
mud.style(
{ "^Round (\\d+) begins\\.$", "^The (\\w+) attacks!$" },
{ capture = 1, fg = "yellow", bold = true }, -- completing-row capture 1
{ within_lines = 3 }
)
mud.replace(
{ "^Status:$", "^HP: (?P<hp>\\d+) MP: (?P<mp>\\d+)$" },
{ capture = 0, with = "Status: {hp}hp / {mp}mp" }, -- {name} substitutes from the completing row
{ within_lines = 2 }
)
The static chain forms only accept static styles and static with strings. Function-valued fg/bg or a function-valued with are rejected with a clear error; if you need dynamic behaviour, use a callback trigger with m:style / m:replace instead. mud.gag doesn't take a chain — wrap it in a callback if needed (mud.trigger(chain, function(m) m:gag() end)).
5.5 Styling & replacement
mud.style, mud.replace, and mud.gag register pattern-matched transformations as standalone triggers (no callback required):
mud.style("\\b(\\d+)hp\\b", { capture = 1, fg = "red", bold = true })
mud.replace("(?P<n>\\d+) gold pieces", function(m)
return string.format("%d gp", tonumber(m.n))
end)
mud.gag("^<tick>$")
The styling option shape, shared with m:style():
-- single target
{ capture = 1, fg = "red", bold = true, italic = false }
-- same style across several captures
{ captures = { 1, 2 }, fg = "#ffaa00", underline = true }
-- per-capture style
{ captures = {
[1] = { fg = "red" },
[2] = { fg = "blue", bold = true },
} }
Colour values accept hex ("#rrggbb"), ANSI names (black, red, green, yellow, blue, magenta, cyan, white, their "light X" variants, plus pink and orange), or a function function(m) return "red" end evaluated at match time. Attributes (bold, italic, underline, reverse, strike) are booleans. At least one of fg, bg, or an attribute must be present.
The chain (multi-line) overloads of mud.style and mud.replace are documented in Chain triggers.
5.6 Delays & timers
mud.delay runs the callback once after ms milliseconds; mud.every runs it on a repeating interval. Both return a handle with :enable(), :disable(), and :remove().
local heartbeat = mud.every(5000, function()
mud.send("score")
end)
mud.delay(1500, function()
mud.note("delayed greeting")
end)
heartbeat:disable() -- pause without removing
heartbeat:enable()
heartbeat:remove() -- unregister
Intervals are milliseconds. Delays and timers are paused while the world is disconnected and resume on reconnect.
The pre-0.6.0 names mud.timer.after(ms, fn) and mud.timer.every(ms, fn) are kept as deprecated aliases for mud.delay and mud.every respectively. They still work, but log a one-shot deprecation warning to the Plugin Activity log on first use per session. New code should use the canonical names.
Trigger and alias action chains have a separate, declarative Delay action managed in the rule editor: when the chain reaches a Delay, the chain pauses for the configured interval and resumes carrying the match captures forward. That carry-captures behaviour is specific to the action-chain Delay — mud.delay from Lua is a plain one-shot timer with no match context.
5.7 vars — per-world user variables
The same variables exposed to triggers/aliases via ${var.name}. Stored as strings: vars.set stringifies numbers and booleans, and nil deletes. The four calls — get, set, delete, snapshot — are documented with signatures in the API reference.
5.8 settings — plugin settings
settings.get (typed value, falling back to the manifest default), settings.snapshot, and settings.on("change", fn) are documented with signatures in the API reference. Settings are read-only from Lua — users change them through the plugin manager, and settings.get reflects the new value on the next read. The behaviour that matters most for plugin authors — when a change applies live versus when it rebuilds the VM — is below.
Live updates vs. VM restarts
When a user changes one of your plugin's settings in the plugin manager, Mallard has two paths:
- No change handler registered. Default behaviour: the plugin VM is rebuilt and your entry file re-runs with the new effective settings. Anything you computed off
settings.get(...)at top-level is recomputed. - At least one
settings.on("change", fn)registered. The VM is preserved; each registered handler is called with(key, new, old)and the plugin applies the change in place. Handlers run in registration order; an error in one handler is logged and doesn't stop the others.
If a particular setting can't be applied live (say, it picks a sounds-file path that's cached at startup), the handler can ask the runtime to restart anyway by calling mud.request_restart(). The restart is deferred until every handler has run, so multiple settings batched together still result in a single rebuild.
settings.on("change", function(key, new, old)
if key == "show_alignment" then
mud.panel("vitals"):set("alignment_visible", new)
return
end
if key == "sounds_pack" then
-- We pre-load the catalog at startup; easiest to rebuild.
mud.request_restart()
end
end)
Calling mud.request_restart() outside a settings handler logs a one-shot warning and is otherwise ignored — there's no other supported entry point into a runtime rebuild from Lua.
5.9 gmcp
gmcp.on("Char.Vitals", function(pkg, data)
mud.panel("vitals"):set("hp", data.hp)
mud.panel("vitals"):set("mp", data.mp)
end)
local current = gmcp.get("Char.Vitals.hp")
gmcp.send("Core.Hello", { client = "mallard" })
gmcp.get reads the auto-mirrored flat store; gmcp.on(prefix, fn) subscribes to packages whose name equals prefix or starts with prefix + "." (callback gets (package, data)); gmcp.send emits a frame. The on/send calls are gated by gmcp_access. Full signatures are in the API reference.
5.10 mxp
mxp.on("Damage", function(tag, attrs, text)
mud.note(string.format("Dealt %s damage to %s", attrs.amount, attrs.target))
end)
mxp.on(tag, fn) subscribes to a custom MXP tag (case-insensitive), firing on the closing tag with (name, attrs, text); built-in tags (B, I, SEND, A, …) are not delivered, and subscribers only fire if the server actually negotiated MXP. Alongside it, mxp.get_entity and mxp.on_entity work with MXP entities. All three are in the API reference.
5.11 keymap
keymap.bind("Ctrl+Shift+H", function()
mud.send("heal self")
end)
keymap.bind("F5", function()
mud.panel("vitals"):post("flash", {})
end)
keymap.bind("Numpad8", function()
mud.send("north")
end)
Combo grammar: zero or more modifiers (Ctrl, Shift, Alt, Cmd, Meta, or Mod — which resolves to Cmd on macOS and Ctrl elsewhere) joined by +, then exactly one key, which can be:
- A letter or digit (case-insensitive matching).
- A named key like
Enter,Escape,Tab,Space,Backspace, or arrow keys. - A function key
F1–F12. - A numpad key:
Numpad0–Numpad9,NumpadAdd,NumpadSub,NumpadMul,NumpadDiv,NumpadDec, orNumpadEnter. Numpad keys bind independently from the main-row digits (soNumpad8and8are distinct), and can carry modifiers (Ctrl+Numpad8).
Modifier-bearing combos and F-keys fire regardless of focus; bare-key bindings fire only outside text inputs. Numpad bindings also fire regardless of focus, which is what makes the visual numpad in the Keymap editor useful as a movement / casting surface during play. Returns a handle with :remove().
5.12 mud.keymap — switch the active keymap config
The end user can define several named keymap configs in Settings → Keymap and switch between them. mud.keymap.* lets a plugin drive that switch from Lua — handy for situational layouts (a "combat" numpad, a "travel" numpad, a "shop" set of aliases on the function row, …).
The switch is driven by mud.keymap.set_active(name) (pass nil to clear the override and fall back to the user's default), mud.keymap.get_active(), and mud.keymap.cycle("next"|"prev") — plus activate/deactivate/toggle and the layer-stack calls, all listed in the API reference.
mud.command("layout", function(m)
if m.args == "" then
mud.note("layouts: travel, combat, shop", { fg = "cyan" })
return
end
mud.keymap.set_active(m.args)
end)
keymap.bind("F12", function()
mud.keymap.cycle("next")
end)
These are switches between user-defined configs, not a way to mint new keymap configs from Lua. The bindings themselves still live in the user's keymap UI; a plugin only controls which config is active.
5.13 keychain
keychain.get, keychain.set (stores a secret in the OS keychain), and keychain.delete are documented in the API reference. Gated by keychain. Keys are namespaced per plugin per world — you can't reach another plugin's secrets.
5.14 events — plugin event bus
A per-world pub-sub channel scoped to your plugin. Useful for splitting a plugin across multiple files without globals.
events.on("combat.start", function(data)
log.info("Combat started against " .. data.target)
end)
events.emit("combat.start", { target = "troll" })
Payloads are JSON-roundtripped — keep them to plain tables, numbers, strings, and booleans. Functions and userdata can't be emitted.
5.15 storage — persistent JSON storage
The six calls — get, set (serialize and persist; nil deletes), has, delete, keys, and byte_size — are documented in the API reference. Per-plugin quota is around 100 MB. Values must be JSON-serializable.
5.16 world — lifecycle events
world.on("connect", function()
mud.send("look")
end)
world.on("disconnect", function()
log.info("disconnected")
end)
world.on("line", function(line)
if line.text:find("^You hear") then
mud.note("[heard] " .. line.text, { fg = "cyan" })
end
end)
Returning true from a "line" handler gags the line. All three return a handle with :remove().
5.17 panel — talking to your iframes
mud.panel(id) returns a handle for one of the panels declared in your manifest. The handle's methods differ slightly by panel kind:
| Method | Available on | Effect |
|---|---|---|
:post(name, data) | custom | Send an RPC message to the iframe. Payload size capped at ~1 MB. |
:on_message(name, fn) | custom | Subscribe to messages from the iframe. |
:layout(spec) | grid, log | Declare or re-declare the panel's structure. Returns the handle for chaining. |
:set(key, value) | grid | Update a named cell's value. |
:log(text, opts?) | log | Append a line. opts.class for CSS-style tagging, opts.timestamp to override the auto timestamp. |
:on_button(key, fn) | grid | Wire up a kind = "button" cell's click. |
Grid layout shape:
mud.panel("vitals"):layout({
kind = "grid",
cells = {
{ key = "hp", kind = "bar", label = "HP" },
{ key = "mp", kind = "bar", label = "MP" },
{ key = "status", kind = "indicator", label = "Status" },
{ key = "look", kind = "button", label = "Look", send = "look" },
},
})
For custom panels, the iframe side gets a global window.panel. The two messaging primitives are:
| JS method | Effect |
|---|---|
window.panel.post(name, data) | Send a message from the iframe to Lua. Picked up by the matching panel:on_message(name, fn) handler on the Lua side. Payload size capped at ~1 MB. |
window.panel.on(name, fn) | Subscribe to messages from Lua. fn receives the data the Lua side passed to panel:post(name, data). |
Match the message names on both sides:
-- Lua
local p = mud.panel("vitals")
p:on_message("ready", function(_) p:post("hp", { value = 87 }) end)
p:on_message("button-clicked", function(data)
mud.send(data.command)
end)
// In ui/vitals.html (loaded as an iframe)
window.panel.post("ready", {});
window.panel.on("hp", ({ value }) => {
document.querySelector("#hp").textContent = value;
});
document.querySelector("#look").addEventListener("click", () =>
window.panel.post("button-clicked", { command: "look" })
);
Opening URLs from a panel
The iframe sandbox does not give plugin content top-navigation or popup rights, so <a href="https://example.com" target="_blank"> is dead by design. To let a plugin render clickable links that go to the OS browser (or a mail client), call:
window.panel.openUrl("https://example.com/changelog");
window.panel.openUrl("mailto:author@example.com");
The host validates the URL protocol (only http, https, and mailto are allowed) before handing it off to the Tauri opener. Anything else is dropped silently. Typical wiring is an anchor with preventDefault:
document.querySelector("a.repo").addEventListener("click", e => {
e.preventDefault();
window.panel.openUrl(e.currentTarget.href);
});
Hover tooltips that escape the iframe
Plugin tooltips render on the host document, so they can extend past the iframe's bounding box (otherwise small panels would clip their own hover surfaces). The host owns layout, positioning, theming, and dismissal — the panel just declares what to show.
Two ways to use it. Declarative — add data-mallard-tooltip to any element with a JSON spec; the SDK auto-shows after ~400 ms hover and hides ~100 ms after the cursor leaves:
<span class="hp"
data-mallard-tooltip='{
"title": "Hit points",
"body": "Regenerates at 1/round at rest.",
"rows": [
{ "label": "Current", "value": "87", "valueColor": "ok" },
{ "label": "Max", "value": "120", "valueColor": "muted" }
]
}'>87 hp</span>
Imperative — call window.panel.tooltip.show / .hide directly. show's first argument is either an Element (the host uses its bounding rect) or an iframe-local { x, y, width, height } rect.
window.panel.tooltip.show(document.querySelector("#hp"), {
title: "Hit points",
rows: [{ label: "Current", value: "87" }],
});
// Later:
window.panel.tooltip.hide();
The tooltip spec shape (all fields optional, but at least one of title, body, or rows must be present or the call is a no-op):
| Field | Type | Notes |
|---|---|---|
title | string | Bold first line. Clamped to 200 chars. |
body | string | Free-text paragraph. Clamped to 1000 chars. |
rows | array of { label, value, valueColor? } | Label/value pairs rendered in a two-column grid. Up to 20 rows; each label and value clamped to 200 chars. valueColor is one of "ok", "warn", "bad", "muted" — these resolve to theme-aware host colours. |
All strings are rendered as text nodes — no HTML, no inline styles. Unknown keys in the spec are silently dropped. The host also installs cursor-leave and iframe-blur safety nets so a tooltip can't get "stuck" if the anchor element is destroyed mid-hover.
5.18 log & ui
Diagnostics:
log.debug("entered combat handler")
log.info("connected")
log.warn("unexpected GMCP shape: " .. tostring(data))
log.error("can't parse score line")
Messages land in the Plugin Activity log (visible from the plugin manager). debug is filtered in normal runs.
Desktop toasts:
ui.notify("Mob killed", "You gained 1280 XP.")
Gated by notifications.
5.19 Theming custom panels
Mallard ships several user-selectable themes (light, dark, and a small set of high-contrast and sepia variants) and lets users tweak the ANSI palette to match their preferred MUD output colours. So that kind = "custom" panels look at home in any of these, the runtime injects a flat set of CSS custom properties into the iframe's :root. They inherit through the cascade, so a panel that styles itself in terms of these tokens — instead of hardcoding hex values — picks up theme switches for free with no message round-trip.
The same variables are available to grid and log panels too (the built-in cells already use them), but you only need to think about them when authoring a custom panel.
Surface & text
| Variable | What it is |
|---|---|
--mallard-bg | Panel background. Match this on html / body so the iframe blends with its dock slot. |
--mallard-bg-elevated | Raised surface: cards, popovers, the active row in a list. |
--mallard-bg-input | Background for <input>, <select>, <textarea>. |
--mallard-bg-hover | Hover wash for interactive rows and buttons. |
--mallard-fg | Primary text colour. |
--mallard-fg-muted | Secondary text: labels, captions, table headers. |
--mallard-fg-subtle | Tertiary text: placeholders, disabled states, watermarks. |
--mallard-border | Default rule for dividers and input borders. |
--mallard-border-strong | Heavier rule for emphasized separators or focused inputs. |
Accent & state
| Variable | What it is |
|---|---|
--mallard-accent | Primary accent. The tray chip uses this; reach for it on primary buttons and selected tabs. |
--mallard-accent-fg | Readable text colour on top of --mallard-accent. |
--mallard-focus-ring | Outline colour for :focus-visible. Use as outline: 2px solid var(--mallard-focus-ring). |
--mallard-selection-bg / --mallard-selection-fg | Text-selection background and foreground; map them to ::selection. |
--mallard-danger / --mallard-warning / --mallard-success / --mallard-info | Semantic state colours. Each has a paired -fg for readable text on top. |
ANSI palette
The 16 standard ANSI slots used by the output pane, plus the two extras (pink, orange) accepted by styling. Use these when your panel needs to colour-match values the user is already seeing in the MUD stream — health bars, chat lines, channel tags.
--mallard-ansi-black, --mallard-ansi-red, --mallard-ansi-green, --mallard-ansi-yellow,
--mallard-ansi-blue, --mallard-ansi-magenta, --mallard-ansi-cyan, --mallard-ansi-white,
--mallard-ansi-light-black, --mallard-ansi-light-red, --mallard-ansi-light-green,
--mallard-ansi-light-yellow, --mallard-ansi-light-blue, --mallard-ansi-light-magenta,
--mallard-ansi-light-cyan, --mallard-ansi-light-white,
--mallard-ansi-pink, --mallard-ansi-orange
The pair --mallard-output-bg / --mallard-output-fg is the output pane's own background and default text colour — useful if a section of your panel is meant to look like an inline continuation of MUD output.
Typography
| Variable | What it is |
|---|---|
--mallard-font-sans | UI font stack. Use on body text, labels, buttons. |
--mallard-font-mono | Monospace stack, matched to the output pane's font. |
--mallard-font-size | Base UI font size in pixels (follows the user's chosen size). |
--mallard-font-size-sm / --mallard-font-size-lg | One step down / up for captions and headings. |
--mallard-line-height | Default line height for body copy. |
Spacing & shape
| Variable | What it is |
|---|---|
--mallard-space-1 … --mallard-space-6 | A 4-px-step spacing scale (4px, 8px, 12px, 16px, 24px, 32px). Use for padding, gaps, and margins so panels line up with built-in chrome. |
--mallard-radius-sm | 3 px — chips, tags, small inputs. |
--mallard-radius-md | 6 px — buttons, cards. |
--mallard-radius-lg | 10 px — modal-like surfaces. |
--mallard-shadow-sm / --mallard-shadow-md | Pre-tuned elevation shadows. Theme-aware: dark themes use lower-opacity shadows automatically. |
Putting it together
A minimally theme-aware panel stylesheet looks like this:
html, body {
margin: 0;
background: var(--mallard-bg);
color: var(--mallard-fg);
font: var(--mallard-font-size) / var(--mallard-line-height) var(--mallard-font-sans);
}
::selection { background: var(--mallard-selection-bg); color: var(--mallard-selection-fg); }
.card {
background: var(--mallard-bg-elevated);
border: 1px solid var(--mallard-border);
border-radius: var(--mallard-radius-md);
padding: var(--mallard-space-3);
box-shadow: var(--mallard-shadow-sm);
}
label { color: var(--mallard-fg-muted); font-size: var(--mallard-font-size-sm); }
input, select {
background: var(--mallard-bg-input);
color: var(--mallard-fg);
border: 1px solid var(--mallard-border);
border-radius: var(--mallard-radius-sm);
padding: var(--mallard-space-1) var(--mallard-space-2);
font: inherit;
}
input:focus-visible { outline: 2px solid var(--mallard-focus-ring); outline-offset: 1px; }
button.primary {
background: var(--mallard-accent);
color: var(--mallard-accent-fg);
border: 0;
border-radius: var(--mallard-radius-md);
padding: var(--mallard-space-1) var(--mallard-space-3);
}
button.primary:hover { filter: brightness(1.05); }
.hp-bar { color: var(--mallard-ansi-red); }
.mp-bar { color: var(--mallard-ansi-blue); }
.warn { color: var(--mallard-warning); }
Theme switches at runtime
When the user changes themes (or tweaks the ANSI palette in Settings → Appearance), Mallard updates the custom properties on every plugin iframe's :root in place. Anything you've styled with var(--mallard-…) repaints automatically; no message comes through panel:on_message. The iframe's root element also carries:
data-theme="light" | "dark"— the broad mode of the active theme. Use this only when a token isn't enough (e.g. swapping an SVG asset between a light and dark variant).data-theme-name="<slug>"— the exact theme slug ("default-light","default-dark","sepia", …). Rarely needed; prefer tokens.
[data-theme="dark"] .logo { background-image: url("../assets/logo-dark.svg"); }
Two anti-patterns to avoid. First, don't branch on prefers-color-scheme — it reflects the OS, not the user's chosen Mallard theme, and will disagree the moment the user picks something other than the default. Second, don't hardcode hex colours next to a token (color: var(--mallard-fg, #1a1a1a)) unless the fallback is genuinely meaningful; the tokens are guaranteed to be set before your stylesheet evaluates, so the fallback just rots when the design system shifts.
6. Lifecycle & sandbox
Each (world, plugin) pair gets a dedicated Lua VM. The VM is created when the world connects and your plugin is enabled; your entry file runs once at that point. Top-level registrations (mud.trigger, mud.command, world.on, keymap.bind, panel layouts, etc.) persist for the life of the VM.
Disabling a plugin doesn't tear the VM down — event delivery is gated off, so triggers stop firing and timers stop ticking, but variables and storage are intact. Re-enabling resumes from the same state. A full reload (the Reload plugins button) does throw the VM away and re-run the entry file.
By default, a settings change is treated as a full reload too. A plugin that wants to handle settings changes in place registers settings.on("change", fn) handlers; while at least one handler is registered, the VM is preserved across changes and the handlers are responsible for re-applying the new value. See settings for details and the mud.request_restart() escape hatch.
The Lua standard library is restricted. Removed entirely: io, os, package, debug, and — since v0.11.0 — coroutine (it would let Lua escape the callback watchdog). Available: string, table, math, utf8, plus the usual globals (print, tostring, tonumber, type, pairs, ipairs, select, error, pcall, xpcall, setmetatable, getmetatable). print routes through log.info. Use storage instead of io for persistence, mud.send instead of opening sockets, require for sibling modules.
Plugin callbacks run under a wall-clock budget: a per-plugin watchdog aborts any single callback that spends more than 750 ms executing Lua, raising an error in that invocation rather than letting it stall the client. Only a runaway loop should hit this — keep handlers short and push long-running work onto timers. The budget meters Lua bytecode, so time blocked inside a host call doesn't count against it.
As of v0.12.0, all of this Lua — per-line triggers and aliases, timers, GMCP, and lifecycle callbacks — runs on a dedicated plugin runtime, isolated from the network and rendering paths. A slow or runaway callback is contained to its own plugin and can no longer block incoming text or the user's input; the watchdog still bounds any single invocation. Settings → diagnostics surfaces each plugin's runtime share and the gating latency input and incoming lines spend waiting on callbacks, so a handler that's monopolising the runtime is easy to spot. See What's new for the upgrade notes.
7. Local dev workflow
A plugin under active development doesn't need to be packed and signed for every iteration. Two paths exist:
- Plugins folder — drop a
.mallardxfile (or an unpacked plugin directory whose top-level name matches the pluginid) into the folder revealed by Plugin manager → Reveal plugins folder. Reload plugins picks it up without restarting the app. - Dev folder — plugins placed under the dev plugins folder are auto-discovered at startup and marked with a dev source pill. They're loaded unpacked from disk, so an edit + reload cycle is one click.
Recommended loop:
- Start with the unpacked dev folder so you can edit Lua in place.
- Use
log.infoliberally and watch the Plugin Activity log. - For custom panels, the iframe gets normal devtools; right-click → Inspect on the panel works.
- Once stable, zip up the directory into
my-plugin-X.Y.Z.mallardxand test installation via Install from file… in the plugin manager.
There is no mallard pack CLI in v1. Any zip tool works — zip -r my-plugin-1.0.0.mallardx . -x '*.DS_Store' from inside the plugin directory is fine. Make sure plugin.toml ends up at the zip's root (or under a single top-level directory named after the plugin id).
8. Publishing to the marketplace
The Mallard marketplace is a curated catalog. Submissions go through a registry repository; CI validates and signs each release with the marketplace's ed25519 key, then republishes the catalog.
The high-level flow:
- Prepare your plugin. Bump the manifest
version, double-check the permission list matches what the code actually calls, and confirmminimum_app_versionis realistic (the latest released Mallard version at maximum). - Build the archive. Produce a clean
.mallardxfrom the plugin directory. Don't include build artefacts, editor backup files, or anything outside the layout in §2. - Open a submission PR against the marketplace registry repo. New plugins add a metadata entry under the plugin's slug — name, description, tags, homepage, the worlds it targets, source repository, and a link to the new archive. Updates bump the version in the existing entry and add the new archive.
- CI validates. The pipeline parses the manifest, lints the Lua, runs a basic static-permission audit (does the code call
mud.sendwhensendsisn't declared?), and rejects on any failure. - Maintainer review. A human looks at what the plugin does, focusing on permissions and any panel HTML for obvious red flags (third-party network calls, sketchy
eval, etc.). - Sign & publish. On approval, CI signs the archive with the marketplace key, uploads archive +
.minisigto the CDN, regeneratesindex.json, and republishes the catalog. Within a few minutes the new version shows up in clients on a Refresh; users with auto-update on for that plugin pick it up on next launch.
What goes into a catalog entry, beyond the manifest:
- Description — one or two sentences. The marketplace card shows this above the tags.
- Tags — short category labels (e.g.
discworld,vitals,combat,mapping). Click-to-filter on the Browse tab. - Homepage / repository — where to file bugs and read source.
- Screenshots — optional, shown on the card detail view.
The registry README in the marketplace repo is the canonical reference for the submission format and review criteria; this section describes the intent rather than the literal file paths.
Updates and breaking changes
Bumping a plugin's version publishes a new release. Users with auto-update on roll forward automatically. Users without auto-update see an Update badge on the Plugins panel; if the new version requests a permission the old one didn't have, the update is held until the user re-confirms.
Breaking changes to the user-facing settings shape are best handled with a one-shot migration in your entry file:
local migrated = storage.get("schema_version") or 0
if migrated < 2 then
-- rewrite legacy keys
storage.set("schema_version", 2)
end
Sideloads
You don't need the marketplace to share a plugin — a .mallardx file can be passed around directly and installed via Install from file…. Sideloads aren't signature-verified and surface that fact in the install dialog. Use them for testing, private plugins, or plugins not yet ready for the curated catalog.