← Back to Mallard

Mallard — Plugin development

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.11.0

If you're updating from v0.10.x, the Lua API surface is unchanged — every namespace, function, and option key is identical, so v0.10.x-targeted plugins keep working. What changed is how the runtime contains a misbehaving plugin. Two things to be aware of:

  • Callbacks now run under a time budget. A per-plugin watchdog aborts any single callback that spends more than 750 ms running Lua, raising an error in that invocation instead of letting it hang the client. The ceiling is wall-clock per callback, sits far above a healthy callback (observed p95s are tens of milliseconds), and only a stuck or runaway loop should ever trip it. The budget covers Lua bytecode — time spent blocked inside a host call doesn't count against it. Keep callbacks short; for anything long-running, break the work up across timers rather than spinning in one handler. A companion slow-plugin diagnostics view (Settings) surfaces the slowest callbacks at or above 150 ms p95, so you can find a sluggish handler before it trips the ceiling.
  • coroutine is no longer available. The standard coroutine library is removed from the plugin sandbox — Lua running inside a coroutine would escape the watchdog's instruction hook, so closing that gap meant dropping coroutines entirely. coroutine == nil in plugin code now. If you were using coroutines for cooperative scheduling, reach for delays and timers instead. This is the one potentially breaking change in v0.11.0; everything else is backward compatible.

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

FieldTypeRequiredNotes
idstringStable identifier. Lowercase alphanumerics, dots, hyphens, underscores. Convention: reverse-DNS, e.g. net.discworld.vitals.
namestringDisplay name shown in the plugin manager and on tray flyouts.
versionstringSemVer. Bump per release; the marketplace surfaces this on the Updates tab.
languagestringMust be "lua" in v1.
entrystringRelative path to the Lua file executed at load. No leading /, no .. segments.
mallard_api_versionstringMinimum Mallard plugin-API version your code targets. Parsed as a ^X.Y.Z requirement.
minimum_app_versionstringLowest Mallard release that satisfies you. Parsed as >=X.Y.Z.
authorsarray<string>Optional, free-form.
licensestringOptional 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.11.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.

KeyShapeEffect
sendsboolAllow mud.send and mud.send_raw.
gmcp_accessarray of glob stringsAllow gmcp.send / gmcp.on for packages whose name matches one of the patterns (e.g. "Char.*", "Room.Info").
notificationsboolAllow ui.notify.
keychainboolAllow 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:

kindWhat it isentry
customAn iframe loading your HTML. Bidirectional RPC with Lua via panel:post / panel:on_message.required — path to the HTML file
gridA built-in grid of declarative cells (bars, indicators, buttons). No HTML to write; you describe cells in Lua.not allowed
logA 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
FieldNotes
titleRequired. Shown on the dockview tab and the tray flyout. The chip's two-letter initial is derived from this.
default_dockOne of left, right, above, below. Hint for where the panel lands the first time the user docks it.
default_sizePixel hint for the panel's initial dimensions.
default_dock_afterAnchor against the named built-in panel (e.g. "output") instead of the active group.
popout_allowedReserved for the pop-out window feature.
max_lines / autoscrollLog-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:

  • Flatplugin.toml sits at the root of the zip alongside src/, ui/, etc.
  • Nested — every entry is under one top-level directory whose name matches the plugin id.

On install Mallard validates:

  • plugin.toml exists 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:

AccessorReturns
mud.worldRead-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-chomp
  • mallard:chime-high
  • mallard:chime-low
  • mallard:chime-vibe
  • mallard:clock-chime
  • mallard:ding-ding-ding
  • mallard:ding-ding-high
  • mallard:ding-ding-low
  • mallard:ding-dong
  • mallard:ding-dong2
  • mallard: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:

KeyMeaning
flagsRegex flags. i case-insensitive, m multiline, s dotall, x verbose.
priorityInteger; higher runs first within the same world.
fires_remainingStop after N matches. nil for unlimited.
enabledInitial enabled state (default true).
nameRe-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:

AccessReturns
m[i]Positional capture i (1-based). Coerced to number when it parses cleanly, otherwise a string.
m.nameNamed capture name (same coercion).
m.textThe full matched line. For chain triggers, this is the completing (last) row's text.
m.namedTable of every named capture, keyed by name. Convenient for iteration.
m.linesPer-row breakdown for chain triggers. Empty for single-line triggers and aliases.
m.argsFor 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:

MethodEffect
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 with m.args set 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.

The match object

The callback receives a standard match object with two notable differences:

  • m.args is 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 "", never nil. Idiomatic check: if m.args == "" then ….
  • m.text is the full submitted line, including the prefix and the command name.
  • No regex captures are involved, so m[1] / m.name / m.lines are not populated. Side-effect methods (m:send, m:note) still work; m:gag(), m:style, and m:replace are 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

KeyMeaning
within_linesHow 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 / nameSame 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 has text (the matched line), captures (1-indexed; captures[1] is the row's overall match, captures[2..] are that row's user captures), and named (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 F1F12.
  • A numpad key: Numpad0Numpad9, NumpadAdd, NumpadSub, NumpadMul, NumpadDiv, NumpadDec, or NumpadEnter. Numpad keys bind independently from the main-row digits (so Numpad8 and 8 are 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:

MethodAvailable onEffect
:post(name, data)customSend an RPC message to the iframe. Payload size capped at ~1 MB.
:on_message(name, fn)customSubscribe to messages from the iframe.
:layout(spec)grid, logDeclare or re-declare the panel's structure. Returns the handle for chaining.
:set(key, value)gridUpdate a named cell's value.
:log(text, opts?)logAppend a line. opts.class for CSS-style tagging, opts.timestamp to override the auto timestamp.
:on_button(key, fn)gridWire 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 methodEffect
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):

FieldTypeNotes
titlestringBold first line. Clamped to 200 chars.
bodystringFree-text paragraph. Clamped to 1000 chars.
rowsarray 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

VariableWhat it is
--mallard-bgPanel background. Match this on html / body so the iframe blends with its dock slot.
--mallard-bg-elevatedRaised surface: cards, popovers, the active row in a list.
--mallard-bg-inputBackground for <input>, <select>, <textarea>.
--mallard-bg-hoverHover wash for interactive rows and buttons.
--mallard-fgPrimary text colour.
--mallard-fg-mutedSecondary text: labels, captions, table headers.
--mallard-fg-subtleTertiary text: placeholders, disabled states, watermarks.
--mallard-borderDefault rule for dividers and input borders.
--mallard-border-strongHeavier rule for emphasized separators or focused inputs.

Accent & state

VariableWhat it is
--mallard-accentPrimary accent. The tray chip uses this; reach for it on primary buttons and selected tabs.
--mallard-accent-fgReadable text colour on top of --mallard-accent.
--mallard-focus-ringOutline colour for :focus-visible. Use as outline: 2px solid var(--mallard-focus-ring).
--mallard-selection-bg / --mallard-selection-fgText-selection background and foreground; map them to ::selection.
--mallard-danger / --mallard-warning / --mallard-success / --mallard-infoSemantic 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

VariableWhat it is
--mallard-font-sansUI font stack. Use on body text, labels, buttons.
--mallard-font-monoMonospace stack, matched to the output pane's font.
--mallard-font-sizeBase UI font size in pixels (follows the user's chosen size).
--mallard-font-size-sm / --mallard-font-size-lgOne step down / up for captions and headings.
--mallard-line-heightDefault line height for body copy.

Spacing & shape

VariableWhat it is
--mallard-space-1--mallard-space-6A 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-sm3 px — chips, tags, small inputs.
--mallard-radius-md6 px — buttons, cards.
--mallard-radius-lg10 px — modal-like surfaces.
--mallard-shadow-sm / --mallard-shadow-mdPre-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 — new in v0.11.0 — coroutine (it would let Lua escape the callback watchdog; see What's new). 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.

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 .mallardx file (or an unpacked plugin directory whose top-level name matches the plugin id) 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:

  1. Start with the unpacked dev folder so you can edit Lua in place.
  2. Use log.info liberally and watch the Plugin Activity log.
  3. For custom panels, the iframe gets normal devtools; right-click → Inspect on the panel works.
  4. Once stable, zip up the directory into my-plugin-X.Y.Z.mallardx and 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:

  1. Prepare your plugin. Bump the manifest version, double-check the permission list matches what the code actually calls, and confirm minimum_app_version is realistic (the latest released Mallard version at maximum).
  2. Build the archive. Produce a clean .mallardx from the plugin directory. Don't include build artefacts, editor backup files, or anything outside the layout in §2.
  3. 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.
  4. CI validates. The pipeline parses the manifest, lints the Lua, runs a basic static-permission audit (does the code call mud.send when sends isn't declared?), and rejects on any failure.
  5. 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.).
  6. Sign & publish. On approval, CI signs the archive with the marketplace key, uploads archive + .minisig to the CDN, regenerates index.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.