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

v0.25.0 gives plugins a real SQL database and a debug log level — the first additions to the Lua surface since v0.19.0's http.*. Everything from v0.24.0 still works unchanged; the one thing that can break you is a GMCP message rename, and only if you are on the server side of it.

  • A per-world SQLite database: db.exec / db.query / db.transaction. When your data is too big or too query-shaped for the key-value storage API — a room map, a spell table, a chat archive — you can now index it and query it with SQL instead of loading a JSON blob into memory. Gated behind a new database permission, with a 100 MB per-(plugin, world) quota and a 2 s statement timeout.
  • You can ship a prebuilt database with your plugin. Declare a [database] section naming a bundled .db file and the tables it owns, and Mallard seeds each world's live database from it at native file-copy speed — so a large dataset is ready before your first db.* call, instead of being imported row by row at startup. When you ship a new seed, only the declared tables are rebuilt; your users' own tables survive.
  • log.debug exists now. Previous versions had exactly three levels and no way to hide development chatter from users — this guide used to tell you to gate it behind your own verbosity setting. Debug lines are always recorded to a new Debug tab in the Plugin Inspector, but are echoed to the output pane only when your plugin is dev-linked, and are kept out of log backload and history search entirely. Use it freely; it costs your users nothing.
  • Log lines name the plugin that wrote them. The prefix is now [info: your.plugin.id] rather than a bare [plugin info], so a user reporting a problem can tell which plugin is talking.
  • MXP tags close at the end of every line. If you register an mxp handler for a custom tag, a tag the server leaves unclosed no longer keeps capturing until it happens to be closed — the capture ends at the line boundary. This is a security fix (an unclosed tag used to be able to keep feeding server output to a plugin indefinitely), and it means a capture spanning multiple lines is no longer possible; handle each line's capture on its own.

Breaking, server-side only: the server-driven plugin install message introduced in v0.24.0 has been renamed from Client.Plugin.Install to mallard.plugin.install, with no alias. If you maintain a MUD that suggests your companion plugin, update it — see the mallard.plugin spec. Nothing in a plugin's own Lua changes, and the consent behaviour is identical.

The dedicated plugin runtime and the 750 ms per-callback watchdog from v0.12.0 are unchanged, as is the http.* API added in v0.19.0, the adapt-colors/bold-brightens rendering toggles from v0.20.0, and the [gmcp] advertise normalization from v0.24.0.

For plugin authors this release is additive — existing plugins keep working with no migration.

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

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.
networkarray of host glob strings(new in v0.19.0) Allow http.get/http.post to reach a host whose name matches one of the patterns (e.g. "api.example.com", "*.githubusercontent.com"). Matched case-insensitively against the request URL's host; an empty or absent list denies all requests.
databasebool(new in v0.25.0) Allow the db.* calls. Required to declare a [database] seed. Shown to the user as "Use SQL database".
[permissions]
sends = true
gmcp_access = ["Char.Vitals", "Char.Status", "Room.Info"]
notifications = true
keychain = false
network = ["api.example.com", "*.githubusercontent.com"]
database = true

Network access is surfaced as its own line in the install-time grant dialog, so the user sees exactly which hosts a plugin intends to reach. A request to a host outside the granted set raises a Lua error at the call site.

A few forward-compatible keys (filesystem, clipboard, external_app) still 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.

(v0.24.0) Declare bare package names; Mallard normalizes each token to the GMCP-required "Package Version" shape when it builds the server's Core.Supports.Set, appending version 1 to a versionless name and correcting a zero version to 1. Your advertises are unioned with every other enabled plugin's for the world and re-sent whenever that union changes.

(v0.25.0) A malformed advertise token is now reported in the app rather than only to stderr, so a mistake in this list is visible while you're developing instead of silently dropping the package.

3.7 Database seed (new in v0.25.0)

If your plugin ships a prebuilt dataset — a room map, a lookup table, a dictionary — bundle it as a SQLite file and let Mallard seed each world's live database from it. The data lands before your first db.* call, at file-copy speed, instead of being imported row by row at startup.

[permissions]
database = true

[database]
seed = "data/seed.db"
shipped_tables = ["rooms"]
FieldTypeNotes
seedstringPath to the bundled SQLite file, relative to the plugin root. No leading /, no .. segments. Must exist and open as SQLite at install time.
shipped_tablesarray<string>The tables Mallard owns and re-creates from the seed. Every name must be a plain (non-virtual) table present in the seed. Must be non-empty.

The two fields are optional, but only together — declaring one without the other is a manifest error, as is a [database] section without the database permission. Install fails if the seed can't be opened, if a declared table is missing from it, or if a declared table is virtual (an FTS5 index can't be re-seeded).

How seeding behaves. On first open for a world, the seed file is the initial database — it's copied wholesale. When a plugin update ships a seed with different content (detected by hash), Mallard drops and repopulates only the shipped_tables, leaving every other table alone: your shipped dataset refreshes and the user's own data survives. If the hash is unchanged, nothing happens.

Treat shipped tables as read-only. Mallard may overwrite them on any re-seed. Writing to one at runtime isn't an error, but the write is discarded the next time a new seed ships. Keep mutable state in your own tables.

-- shipped table: read only
local rows = db.query("SELECT name FROM rooms ORDER BY id")

-- your own table: mutable, and yours to migrate
db.exec("CREATE TABLE IF NOT EXISTS bookmarks(name TEXT)")

Mallard manages shipped_tables and nothing else, so migrations for your own tables are your job — run idempotent CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS statements at load.

Constraints worth knowing: the bundled file must be a single checkpointed .db with no -wal/-shm sidecars (build it in rollback-journal mode, or PRAGMA wal_checkpoint(TRUNCATE) and close cleanly before packaging) — the installer rejects seeds with sidecars. The table name _mallard_seed is reserved for Mallard's fingerprint bookkeeping. Each world gets its own copy, and that copy counts against the same 100 MB quota as the rest of your database. For a dev-linked plugin the live database is cached for the session, so a seed you change mid-session isn't re-applied until the app restarts.

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.

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

(v0.25.0) Captures end at the end of the line. MXP is line-oriented, and Mallard now closes every open tag at the line boundary. A tag the server never closes used to keep capturing — and keep handing you server output — indefinitely; now it delivers what it captured on that line and stops. In practice this means a capture can't span a newline, so if you were relying on a multi-line capture arriving as one text, accumulate across calls in your own state instead.

(v0.25.0) Line security keeps forged tags away from your handler. Mallard now implements MXP's per-line security model, and a custom tag — anything that reaches mxp.on — is classified secure, meaning it is honoured only on a line the server has marked as its own. On a player-influenced line the markup renders as plain text and your subscriber does not fire, so a player typing <Damage amount="999"> into a say can't forge an event at you. Two caveats worth knowing while testing: under the default auto policy enforcement starts only once the server sends its first line-mode marker (a world that never sends one is unprotected, by design, so legacy servers keep working), and a user can disable enforcement outright with /global set mxp-security permissive. Validate attributes anyway.

Also new in v0.25.0, and otherwise invisible from Lua: Mallard answers the server's <VERSION> and <SUPPORT> capability queries, decodes entity references, supports ADD/REMOVE list entities, and understands the full HTML colour vocabulary plus #RRGGBB/#RGB hex.

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 — cross-plugin event bus

A pub-sub channel shared by every plugin running on the world. This is the intended way for plugins to talk to each other: one plugin emits, any other plugin that subscribed to that name receives it. It works for intra-plugin messaging too, but that's generally a bit overkill unless you just really want to architect around a broadcast and subscribe pattern.

Your combat tracker can announce what it sees, and someone else's healer plugin — written by someone you've never met — can act on it:

-- in com.example.combat
events.emit("com.example.combat.start", { target = "troll", hp = 340 })

-- in com.example.healer, a different plugin entirely
events.on("com.example.combat.start", function(data)
  log.info("Combat started against " .. data.target)
end)

Name your events like you name your plugin. Because the namespace is shared world-wide, a bare "combat.start" is asking for a collision with the next plugin that has the same idea. Prefix event names with your plugin id (or its reverse-DNS root) and treat the names you emit as public API: other plugins will come to depend on them, so document them and don't rename them casually.

Details worth knowing:

  • Scope is the world, not the plugin. Each world has its own bus, so an event emitted while connected to one world never reaches plugins on another.
  • The emitter hears itself. Listeners aren't filtered by who emitted, so if you subscribe to a name you also emit, your own handler fires.
  • Dispatch is synchronous. events.emit returns after every listener has run. A listener that errors is logged (and shows up in that plugin's Errors tab) without stopping the remaining listeners.
  • Re-entry is capped. An emit chain nested more than 16 deep is dropped, so an accidental A→B→A loop stalls instead of hanging the world.
  • Payloads are JSON-roundtripped — keep them to plain tables, numbers, strings, and booleans. Functions and userdata can't be emitted.
  • Nothing is guaranteed to be listening. Emitting into a world where the other plugin is missing or disabled is a no-op, not an error. Design your events as announcements, not as calls that must be answered.

Because any plugin can subscribe, treat incoming event data the way you'd treat server output: validate it before acting on it, and don't put secrets in a payload.

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 50 MB. Values must be JSON-serializable.

This is the right tool for a handful of small values — settings-adjacent state, a last-seen marker, a modest table. When you need indexed lookups, aggregates, or a dataset you'd rather not hold in memory, use db instead.

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.info("connected")
log.warn("unexpected GMCP shape: " .. tostring(data))
log.error("can't parse score line")
log.debug("matched room header", m[1])   -- new in v0.25.0

info, warn, and error are user-visible. Each is written to the world's main output and to the persistent per-world logs, so anything you log at these levels is in front of the user on every run. There is no level filtering between them. (v0.25.0) Lines now carry the plugin that wrote them — [info: your.plugin.id] — where earlier releases showed a bare [plugin info].

(new in v0.25.0) log.debug is the level for you, not your users. It behaves differently from the other three:

  • Always recorded to your plugin's Debug tab in the Plugin Inspector, whether or not the plugin is dev-linked — so you can ask a user to open the Inspector and read back what happened.
  • Echoed to the output pane only when the plugin is dev-linked. For an installed plugin the line never reaches the user's screen.
  • Excluded from log backload and history search, so debug output can't clutter the scrollback the user searches.

Debug lines are prefixed [debug: your.plugin.id]. Because it costs an installed user nothing, you no longer need to gate development chatter behind your own verbosity setting — reach for log.debug instead of a hand-rolled toggle, and keep info for things a user actually wants to read.

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.

5.20 Send observers & mud.execute (new in v0.16.0)

Two v0.16.0 additions sit on the outbound side of the client — one watches commands leaving for the wire, the other pushes a line into the input pipeline as if the user had typed it. Both are documented with full signatures and option tables in the API reference.

mud.on_send — observe outbound commands

mud.on_send registers an observe-only callback that runs for every command Mallard sends — it cannot alter, cancel, or rewrite the send, it just watches it go by. Call it with a pattern to watch only matching sends, or with no pattern to watch every one:

-- watch only movement commands
mud.on_send([[^(n|s|e|w|u|d)$]], function(m)
  mud.note("moved: " .. m[1] .. " (origin: " .. m.origin.kind .. ")",
    { fg = "#88ccff", italic = true })
end, { name = "movement-observer" })

-- watch every send
mud.on_send(function(m)
  if m.silent then return end          -- ignore echo-suppressed sends
  log.info("sent: " .. m.text)
end)

The callback receives a match object shaped like a trigger's, plus two fields specific to observed sends:

AccessReturns
m[i] / m.namePositional / named captures from the observer's pattern (same coercion as triggers). No captures when you registered without a pattern.
m.textThe full command being sent.
m.originTable { kind, plugin_id? } describing where the send came from. kind is one of "typed" (the user typed it), "alias", "command", "trigger", "keymap", "plugin" (a mud.send / raw send), "execute" (a mud.execute line that matched no rule), or "script". plugin_id is present when the send originated in plugin Lua.
m.silentBoolean: whether the send suppressed its local echo (the { silent = true } opt).

Options mirror triggers, minus one: flags (regex flags for the pattern), priority (higher observers fire first), enabled (initial state, overridable by a persisted plugin-rule setting), and name (stable rule key — re-registering with the same name replaces the previous observer). Unlike mud.trigger, fires_remaining has no effect on an observer. The call returns the usual handle (:enable(), :disable(), :remove(), read-only id/kind/enabled), and the observer appears in the plugin rules browser alongside your triggers and aliases.

Because observers are observe-only, they're the right tool for logging, metrics, and mirroring outbound traffic to a panel — not for rewriting what gets sent. To change or gag an outgoing line, use a regular alias; to change an incoming line, use a trigger.

mud.execute — run a line through the input pipeline

mud.execute(text, opts?) feeds a string into the same input pipeline that handles typed input: alias expansion, slash-commands, and triggers all apply, and any resulting send goes out to the world. It's the counterpart to mud.send, which bypasses all of that and writes straight to the wire.

mud.trigger("^You are hungry\\.$", function()
  mud.execute("eat bread")          -- runs through aliases/commands, not just the wire
end)

mud.execute("/vault deposit 100")   -- dispatches a client command
mud.execute("look", { silent = true })  -- suppress the echo of the resulting send
KeyMeaning
silentBoolean, default false. When true, the resulting send suppresses its local echo. Must be a boolean or the call raises. Unknown keys are rejected.

A recursion guard caps nesting depth, so a trigger or send observer that calls mud.execute can't loop back on itself indefinitely — exceeding the cap raises a runtime error. A plain line that matches no alias, command, or trigger is sent with origin kind "execute", which is what an mud.on_send observer sees in m.origin.kind — distinct from a user's "typed" send and from mud.send's "plugin" send. The call returns nothing.

5.21 Text-to-speech (new in v0.17.0)

v0.17.0 adds a speech surface on mud for reading text aloud through the host TTS engine: mud.speak queues an utterance, mud.stop_speech stops one, and mud.voices lists the installed voices. All three are documented with full signatures and option tables in the API reference. The same capability is available declaratively as the Speak and Stop speech trigger/alias actions, so simple "say this when that scrolls by" cases need no Lua at all.

Speech is governed by the user's global settings (Settings → Speech, or /global speak-*): a master speak-enabled toggle, a default speak-voice, and default speak-rate, speak-pitch, and speak-volume values. Per-call options override those defaults, but the master toggle wins — when the user has speech disabled, mud.speak is a no-op and the utterance is dropped without queueing. Treat speech as an enhancement, never as the only channel for something important.

mud.speak — read text aloud

mud.speak(text, opts?) queues text (which must be non-empty) as a spoken utterance. With no options it uses the user's global voice and rate/pitch/volume; the opts table overrides any of those per call, and adds channel/interrupt for controlling how utterances line up:

-- simple: speak with the user's configured voice and settings
mud.trigger("^(\\w+) tells you, ", function(m)
  mud.speak(m[1] .. " sent you a tell")
end)

-- a "combat" lane that always speaks the latest, cutting off the previous line
mud.speak("You are badly wounded!", {
  channel   = "combat",
  interrupt = true,
  rate      = 0.3,        -- a touch faster than normal (0)
})
KeyMeaning
channelString lane label. Utterances on the same channel are spoken in order; a line identical to the one already queued on that channel is coalesced (so a repeated trigger doesn't stutter). Omitted means the default lane.
interruptBoolean, default false. When true, clears anything queued on this channel and cuts off the line currently speaking on it before this one — the "only the latest matters" pattern.
voiceVoice id from mud.voices(). Omitted falls back to /global speak-voice, then the system default.
rate / pitchNumbers in -1..1 where 0 is normal. Omitted falls back to the matching global setting.
volumeNumber in 0..1. Omitted falls back to /global speak-volume.

Empty text, a non-numeric rate/pitch/volume, or an unknown option key all raise. The call returns nothing.

mud.stop_speech — stop speaking

mud.stop_speech(channel?) stops speech. Pass a channel to clear just that lane's queue and cut off its active utterance; pass nothing to stop everything, queued and speaking:

mud.stop_speech("combat")   -- silence only the combat lane
mud.stop_speech()           -- stop all speech immediately

mud.voices — list installed voices

mud.voices() returns an array of the voices the host TTS engine offers, each a table with id, name, and language. The id is what you pass as the voice option to mud.speak (or what a user sets as /global speak-voice):

for _, v in ipairs(mud.voices()) do
  log.info(string.format("%s — %s (%s)", v.id, v.name, v.language))
end

Available voices are whatever the operating system's speech engine exposes, so the list differs per machine — don't hard-code a voice id in a published plugin; offer it as a setting or pick from mud.voices() at runtime instead.

5.22 HTTP requests (new in v0.19.0)

v0.19.0 adds an http global for making network requests from a plugin: http.get and http.post. Both are asynchronous — they return immediately, and your callback runs on a later tick once the response (or an error) is in. The requests execute off the main thread with up to four in flight per plugin; further calls queue behind them, so a burst won't block the client. Full signatures are in the API reference; this section covers the shape and the gotchas.

Every request is gated by the network permission: the URL's host must match one of your declared host globs or the call raises synchronously, before anything is sent. Only https:// is accepted — plain http:// is allowed for localhost only — and HTTPS is re-enforced on every redirect hop, so a request can't be quietly downgraded mid-redirect.

http.get / http.post — make a request

Both take the URL first and a callback last, with an optional options table in between: http.get(url, callback) or http.get(url, opts, callback) (and the same for post). The callback receives a single res table:

-- register a client command that fetches a wiki summary
mud.command("wiki", function(args)
  http.get("https://api.example.com/wiki/" .. args, function(res)
    if not res.ok then
      log.warn("wiki lookup failed: " .. tostring(res.error or res.status))
      return
    end
    local data = res:json()
    if data then mud.note(data.title .. ": " .. data.extract) end
  end)
end)

-- POST a JSON body; `json` encodes the table and sets Content-Type for you
http.post("https://api.example.com/events", {
  json    = { kind = "login", who = "orc" },
  headers = { ["x-plugin"] = "vitals" },
  timeout_ms = 5000,
}, function(res)
  if not res.ok then log.warn("post failed") end
end)

The options table accepts headers (a string→string table; the Host and Content-Length headers can't be overridden), one of body (a raw string) or json (any Lua value, encoded to JSON — setting them both raises), timeout_ms (default 30 000, capped at 120 000), and max_bytes (default 5 MiB, capped at 25 MiB; a larger response fails). An unknown option key raises.

The res response table

Your callback is handed one table describing the outcome:

FieldMeaning
okBoolean — true for a 2xx status. Check this first.
statusHTTP status code as an integer, or nil if the request never completed (a transport error).
errorError string on a transport failure (timeout, DNS, connection), or nil on any HTTP response — including a 404. A 404 is ok = false with error = nil.
bodyResponse body as a string, or nil on a transport error.
headersResponse headers as a string→string table with lowercased keys.
res:json()Method that lazily parses body as JSON. Returns the decoded value (objects/arrays become tables, null becomes nil) on success, or nil plus an error-message string on a parse failure or when there's no body.

Note: http.* callbacks run on the plugin runtime under the same 750 ms watchdog as every other callback, so do the light work (parse, update a panel, fire a note) in the handler and keep it short — the request itself is already off-thread. Treat the network as best-effort: always branch on res.ok and have a sensible path when a request fails or times out.

5.23 db — SQL database (new in v0.25.0)

Permission-gated by database. Every plugin gets one private SQLite database per world it runs in — world A's data is entirely separate from world B's, and no plugin can reach another's. Reach for it when storage stops fitting: when you want an index, an aggregate, a join, or a dataset larger than you'd care to hold in a Lua table.

-- Idempotent schema setup at load. Your tables are yours to migrate.
db.exec([[
  CREATE TABLE IF NOT EXISTS rooms (
    name TEXT PRIMARY KEY,
    seen INTEGER NOT NULL DEFAULT 0
  )
]])
db.exec("CREATE INDEX IF NOT EXISTS rooms_by_seen ON rooms(seen)")

world.on("line", function(line)
  local room = line.text:match("^You are in (.+)%.$")
  if room then
    db.exec([[
      INSERT INTO rooms(name, seen) VALUES (?, 1)
      ON CONFLICT(name) DO UPDATE SET seen = seen + 1
    ]], { room })
  end
end)

mud.command("rooms", function()
  local rows = db.query("SELECT name, seen FROM rooms ORDER BY seen DESC LIMIT 10")
  for _, r in ipairs(rows) do
    mud.note(("%-30s %d visits"):format(r.name, r.seen))
  end
end)

Three calls. db.exec(sql [, params]) runs a statement that returns no rows and gives back the number of rows changed — CREATE, INSERT, UPDATE, DELETE, PRAGMA. db.query(sql [, params]) runs a SELECT and returns a 1-indexed array of rows, each a table keyed by column name (an empty result is an empty table, not nil). db.transaction(fn) wraps fn in BEGIN/COMMIT and passes through its first return value; if fn raises, the transaction rolls back and your error is re-raised.

Always bind parameters. Placeholders are positional ? markers, filled from a dense array table in order. Don't concatenate MUD output into SQL — a room name with an apostrophe is enough to break it, and server text is untrusted input.

db.exec("UPDATE rooms SET seen = seen + 1 WHERE name = ?", { "Town Square" })

Dense matters: Lua's # stops at the first nil hole, so {1, nil, 3} has length 1 and only the first placeholder binds — the rest raise at bind time.

Batch writes in a transaction. SQLite fsyncs after every auto-commit statement, so a bulk insert wrapped in one db.transaction is dramatically faster than the same inserts run bare:

local n = db.transaction(function()
  local count = 0
  for _, name in ipairs(names) do
    db.exec("INSERT INTO rooms(name) VALUES (?)", { name })
    count = count + 1
  end
  return count
end)
log.debug("inserted", n, "rooms")

Transactions don't nest — calling db.transaction inside another raises — and only fn's first return value survives.

Type mapping. Lua nil binds as NULL, booleans as integer 1/0, integers as INTEGER, floats as REAL, strings as TEXT; a table, function, or thread raises at bind time. Reading back, two asymmetries bite:

  • NULL reads back as nil, which in a Lua table is indistinguishable from a column you didn't select. Test for it in SQL with IS NULL rather than in Lua.
  • Booleans come back as 1/0, not true/false — SQLite has no boolean type. Write if row.flag == 1 then.

BLOB columns read back as Lua strings; bound strings must be valid UTF-8, so encode binary data as hex or base64.

Limits. All three raise ordinary catchable Lua errors — wrap in pcall where you want to recover.

LimitValueError
Quota~100 MB per (plugin, world)database quota exceeded
Statement timeout~2 squery exceeded time limit and was aborted
Rows per query~500,000raises rather than truncating — add a LIMIT

Sandbox and threading: ATTACH is disabled and extension loading is permanently off, so a plugin cannot reach any other database file — including Mallard's own. All three calls are synchronous on the plugin callback thread, under the same 750 ms watchdog as every other callback: index what you query, keep per-line work to a single bound statement, and push anything expensive behind a command rather than a trigger.

Shipping a prebuilt dataset instead of building one at runtime? See §3.7.

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 is available but its output is not surfaced in the client — it goes to process stdout, which shipped builds don't attach, so use log.info for anything you want to see. 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 .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.