← 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, 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.8.0

If you're updating from v0.6.0 — there was no plugin-dev release for v0.7.0 because the plugin API surface didn't move — these are the additions and behaviour changes to be aware of. None break v0.6.0-targeted plugins; everything below is additive. v0.8.1 is a bugfix-only release: no plugin API changes, so this page covers the entire v0.8.x series.

  • Silent sendmud.send, mud.send_raw, and m:send now accept a second arg { silent = true } that suppresses local echo for that one call, regardless of the world's local-echo setting. The trigger/alias Send action gained a matching Silent checkbox. See mud namespace and match object.
  • Bundled sounds — the client ships with 11 curated chimes addressable as mallard:<id>. They work anywhere a sound name does (mud.play_sound("mallard:ding-dong"), the PlaySound trigger action). No filesystem access required. See Bundled sound IDs.
  • Plugin hover tooltips — custom panels can now show host-rendered tooltips that escape the iframe's bounding box. Imperative (window.panel.tooltip.show / .hide) or declarative (data-mallard-tooltip="…"); the host owns layout, theming, and dismissal. See panel.
  • Safe URL launchwindow.panel.openUrl(url) from a custom panel hands an http(s): or mailto: URL to the OS opener. Replaces the old "intercept anchor clicks in the plugin" workaround. See panel.
  • Panel SDK renamed — the iframe-side global is now window.panel (not window.mudPanel), and the verb for plugin → Lua messages is panel.post(name, data) (not .send). Earlier docs reflected an intermediate name; the shipped API is and always has been window.panel.post / window.panel.on.

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

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

FunctionEffect
mud.send(text, opts?)Send a command. Newlines in text fan out into separate sends. opts.silent = true suppresses local echo for this call (overrides the world's Local echo setting). Gated by sends.
mud.send_raw(bytes, opts?)Send raw bytes, no newline handling. Same opts.silent as above. Gated by sends.
mud.note(text, opts?)Insert a styled note line into the output pane. opts accepts fg, bg, bold, italic, underline, reverse, strike.
mud.play_sound(name, opts?)Play a sound. name is either the basename of a file in the world's sounds folder ("alert.wav") or one of the bundled chime IDs ("mallard:ding-dong"). opts.volume (0–100), opts.tag for later stop targeting.
mud.stop_sounds(opts?)Stop currently-playing sounds. opts.tag narrows by tag.
mud.worldRead-only table: { id, name, host, port, character }.
mud.viewport()Fresh table { cols, rows } with the output pane's current cell dimensions.
mud.panel(id)Return a panel handle for the given declared panel id. See panel.

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.

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.

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

5.4 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.5 vars — per-world user variables

The same variables exposed to triggers/aliases via ${var.name}. Stored as strings.

FunctionEffect
vars.get(name)String value, or nil if unset.
vars.set(name, value)Set. Numbers and booleans are stringified. nil deletes.
vars.delete(name)Explicit delete.
vars.snapshot()Plain table of every variable.

5.6 settings — plugin settings

FunctionEffect
settings.get(key)Typed value (bool / number / string), falling back to the manifest default. Warns if the key isn't declared.
settings.snapshot()Table of every declared setting's current effective value.

Settings are read-only from Lua. Users change them through the plugin manager; your code observes the new value on next read.

5.7 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" })
FunctionEffect
gmcp.get(path)Read the auto-mirrored flat store. Returns the raw string value or nil.
gmcp.on(prefix, fn)Subscribe to packages whose name equals prefix or starts with prefix + ".". Callback gets (package, data); data is the decoded JSON payload. Returns a handle with :remove(). Gated by gmcp_access.
gmcp.send(package, data?)Send a GMCP frame. data is JSON-encoded if present. Gated by gmcp_access.

5.8 mxp

mxp.on("Damage", function(tag, attrs, text)
  mud.note(string.format("Dealt %s damage to %s", attrs.amount, attrs.target))
end)
FunctionEffect
mxp.on(tag, fn)Subscribe to a custom MXP tag (case-insensitive). Fires on the closing tag with (name, attrs, text). Builtin tags (B, I, SEND, A, etc.) are not delivered.
Subscribers only fire if the server actually negotiated MXP. Returns a handle with :remove().

5.9 keymap

keymap.bind("Ctrl+Shift+H", function()
  mud.send("heal self")
end)

keymap.bind("F5", function()
  mud.panel("vitals"):post("flash", {})
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 (letter, digit, named key like Enter / Escape / arrow keys, or F1F12). Modifier-bearing combos and F-keys fire regardless of focus; bare-key bindings fire only outside text inputs. Returns a handle with :remove().

5.10 keychain

FunctionEffect
keychain.get(key)Read a secret. Returns string or nil.
keychain.set(key, value)Store a secret in the OS keychain.
keychain.delete(key)Remove.

Gated by keychain. Keys are namespaced per plugin per world — you can't reach another plugin's secrets.

5.11 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.12 storage — persistent JSON storage

FunctionEffect
storage.get(key)Deserialized value, or nil.
storage.set(key, value)Serialize and persist. nil deletes.
storage.has(key)Boolean existence check.
storage.delete(key)Explicit delete.
storage.keys()Array of every key.
storage.byte_size()Bytes used by this plugin across all worlds.

Per-plugin quota is around 100 MB. Values must be JSON-serializable.

5.13 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.14 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.15 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.16 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, 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.

The Lua standard library is restricted. Removed entirely: io, os, package, debug. Available: string, table, math, utf8, coroutine, 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.

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.