@runmu.sh/sdk
v1.16.0
Published
Types and helpers for μClient extensions. Install it as the @muclient/sdk alias ("@muclient/sdk": "npm:@runmu.sh/sdk@^1.13.0"): at runtime μClient supplies @muclient/sdk through its import map, so this package is for the type checker and stays external in
Maintainers
Readme
@runmu.sh/sdk
The types of the μClient extension SDK (mu), plus defineExtension and SDK_VERSION.
Extensions import it as @muclient/sdk, the name μClient's import map resolves at runtime. Install
it under that name with an npm alias:
npm i -D @muclient/sdk@npm:@runmu.sh/sdk@^1.12.0{ "devDependencies": { "@muclient/sdk": "npm:@runmu.sh/sdk@^1.12.0" } }Keep @muclient/sdk external in your bundle (the template's build does). The client supplies the real
module, so this package is only there for the type checker.
npm create @runmu.sh/extension my-ext sets this up. Docs: https://runmu.sh/docs/extensions/ and
the member reference at https://runmu.sh/docs/reference/sdk/. Source of truth:
clients/extensions/sdk/index.ts in the μClient repository.
What changed in each version: CHANGELOG.md.
Settings, storage and core preferences (1.9)
Settings
mu.settings.define(schema) generates a page under Settings → Extensions. It can also add rows to a core page
(sections) and mount a component of your own below the rows (component).
mu.settings.define({
items: [
{ key: 'sound', label: 'Play sounds', default: true, sync: 'device' }, // per device
{ key: 'volume', label: 'Volume', default: 0.5, min: 0, max: 1, step: 0.05, when: { key: 'sound', equals: true } },
{ key: 'label', label: 'Room label', default: '', scope: 'world' }, // per world, every device
{ key: 'template', label: 'Template', default: '', kind: 'textarea' },
],
sections: [{ page: 'alerts', title: 'Room notes', keys: ['sound'] }],
});
mu.settings.get('label', { sid }); // the world of that session
mu.settings.watch('label', (v, meta) => { /* meta.origin.self, meta.replay */ }, { sid });The default is sync: 'account': the value follows the player to every device. Concurrent writes resolve
per key by a hybrid logical clock (HLC).
Core preferences
mu.prefs is read-only. Each name is a stable contract, not a raw pref key:
mu.prefs.watch('a11y.reduceMotion', (on) => { if (on) stopAnimation(); else startAnimation(); });
mu.prefs.get('audio.volume'); // 0..1The names are:
a11y.reduceMotion,a11y.screenReader,a11y.speak;effects.calm,effects.performance,effects.glow;theme.id;audio.volume,audio.muted;text.fontSize,text.fontFamily;locale.
For anything visual, prefer CSS tokens (var(--…), data-theme, data-calm and data-perf on <html>).
Storage tiers
| Tier | Where it lives | Quota |
|---|---|---|
| mu.storage.device (= global), mu.storage.world(id) | this device (IndexedDB); other tabs see the writes | 5 MB |
| mu.storage.account | every device of the player, end-to-end encrypted | 512 KB |
| mu.storage.world(id, { sync: true }) | that world on every device, encrypted with the world key | 2 MB per world |
| mu.storage.session(sid?) | the clients attached to one session, in memory | 256 KB |
Every tier offers:
- synchronous
get(key, fallback?); set,delete,keys(prefix?);watch(key, (value, meta) => …)andwatchAll;ready, which resolves once the tier is loaded. The host awaits every tier beforeactivate, for 2 s at most.
A write over the quota throws QuotaExceeded (import it from @muclient/sdk).
const notes = mu.storage.world(undefined, { sync: true }).collection<{ room: string; text: string }>('notes');
const id = notes.add({ room, text }); // a ULID: concurrent adds on two devices both survive
notes.patch(id, { text: 'edited' }); // merged per field
notes.remove(id);
notes.watch(({ id, value, meta }) => render());Declare what you store in the manifest ("contributes": { "storage": { "account": ["notes"] } }). The player
sees it in the uninstall prompt and the backup. Uninstalling with "delete data" deletes every tier on every
device.
Several clients on one session (1.9)
A player can have one session open in several tabs and on several devices at once. Every client runs your extension, so think about what should happen once.
State runs everywhere; effects run where the player is. A sound, a toast or a notification caused by the game
plays on one client: the one the player last focused or typed in (the attention owner). When nobody is looking,
the leader tab of each device plays it. media.play and ui.toast follow this rule themselves. For your own
effects (a WebAudio cue), ask:
if (mu.effects.owner(meta.sid)) beep();
mu.effects.watch((owner) => (indicator.hidden = !owner), sid);Sends that must happen once take a key. The backend writes a key once per session, for 10 minutes, scoped to your extension:
mu.sessions.on('line', (l, meta) => {
if (/tells you/.test(l.text)) void mu.sessions.send('reply busy', { sid: meta.sid, key: `${meta.id}:reply` });
});
await mu.gmcp.send('Char.Skills.Get', {}, { sid, key: `${meta.id}:skills` });Live values between your instances go through a sync channel. Values are encrypted, never stored, and at most 64 KB each. A channel never hears its own publishes:
const draft = mu.sync.channel<string>('draft'); // session scope; { scope: 'world' | 'account' }
draft.on((text, meta) => (box.value = text)); // meta.origin.conn: which client sent it
void draft.publish(box.value);
draft.last(); // the latest value from another clientProtocols and GMCP (1.10)
Core reads no game protocol for you any more. Bundled protocol adapters do it: Room.*, Comm.Channel.*, Client.Media.*, MSP, MSDP and the MXP media tags. They work through this same SDK and feed mu.scene, mu.channels and mu.media, so most extensions never touch raw GMCP.
Typed GMCP. Augment the package table, and on, state, watch and request are typed:
declare module '@muclient/sdk/gmcp' {
interface GmcpPackages { 'Client.Tickets.Thread': { id: string; messages: unknown[] } }
}
mu.gmcp.watch('Char.Vitals', (v) => (bar.value = Number(v?.hp)), { sid }); // now (replay), then each changeAsk and wait. request sends once per key and resolves with the answer. maxAgeMs answers from fresh state without sending:
const inv = await mu.gmcp.request('Char.Items.Inv', {}, { sid, expect: 'Char.Items.List', match: (d) => d.location === 'inv', timeoutMs: 3000 });
mu.gmcp.supported('Char.Skills', sid); // the advertised version, or nullCore packages (Core.Hello, Core.Supports.*, Core.KeepAlive, Core.Ping, Char.Login*) are the backend's. send resolves 'reserved' for them, and request rejects.
MSDP, MXP, telnet:
const off = mu.msdp.report(['HEALTH', 'HEALTH_MAX'], sid); // the backend counts REPORTs across clients
mu.msdp.on('HEALTH', (v) => (hp = Number(v)), { sid });
mu.mxp.on('element', (el) => { if (el.tag === 'sound') play(el.attrs.fname); });
mu.mxp.define('hp', { render: (a, text) => ({ text, style: { fg: 'alert', bold: true } }) }); // theme tokens only
mu.telnet.options(sid); // ['GMCP', 'MSDP', 'NAWS', ...]
mu.mssp(sid); // { NAME: 'Underspire', PLAYERS: '12', ... } or nullProviders. Feed the core models from your own protocol. A scene field takes the highest priority; disposing your provider hands that field back to the next one:
const d = mu.scene.provide(sid, { name: 'Chapel of Ash', exits: ['n', 'd'] }, { priority: 10 });
mu.channels.provide(sid, { list: [{ name: 'ooc' }], message: { channel: 'ooc', sender: 'Vex', text: 'hi' } });Lua. mu.lua.emit('dice', { n: 2 }, { sid }) reaches backend Lua ext.on('dice', fn). Lua can also send GMCP itself with gmcp.send(pkg, data).
Debug with Tools → GMCP inspector. It shows what arrives and what goes out (with the extension that sent it), the reduced state, and who declared each supported package.
MCP and editors (1.10)
On MOO worlds the backend speaks MCP 2.1 (the handshake, its key, negotiation, multiline values, cords). You get decoded messages. Declare the packages you handle so they are negotiated while your extension is live:
{ "muclient": { "contributes": { "mcp": [{ "package": "dns-com-example-weather", "min": "1.0", "max": "1.0" }] } } }mu.mcp.on('dns-com-example-weather', (args, meta) => show(meta.sid, args.sky)); // exact, or a prefix ending in '-'
const r = await mu.mcp.send('dns-com-example-weather-get', { where: 'here' }, { sid, key: `${meta.id}:wx` });
// r: 'sent' | 'duplicate' | 'not-negotiated' | 'refused'
mu.mcp.version('dns-com-example-weather', sid); // '1.0', or null
const cord = await mu.mcp.cord('whiteboard', { sid }); // closed when you are disabled
cord.on('*', (args) => draw(args));
mu.mcp.onCordOpen('whiteboard', (c) => c.on('line', draw)); // claim server-opened cordsA package enabled mid-session is offered at once. If the MOO does not take it within 5 s, the player is told to
reconnect. A multiline value is a string[]. Everything is encrypted with the world key on the way.
Editors. When the game hands the player text to edit, use mu.editor.open. It opens on the device the player
is using, and the other devices show "Editing … on another device":
const ed = mu.editor.open({
sid, id: ref, title: name, text: lines.join('\n'), language: 'moo-code',
save: (text) => mu.mcp.send('dns-org-mud-moo-simpleedit-set', { reference: ref, type, content: text.split('\n') },
{ sid, key: `set:${ref}:${hash(text)}` }).then((r) => { if (r !== 'sent' && r !== 'duplicate') throw new Error(r); }),
});
ed.update(newText); // asks first if the player has unsaved editsIf save rejects, the editor stays open with the error. Give command when saving sends a command as the player:
the editor shows it, and an unusual verb asks first.
The bundled adapters in clients/adapters/ (simpleedit, status, userlist, smartcomplete, displayurl) are built
on exactly this API. They are good examples.
Lines and input (1.11)
Line stages run by phase, so a highlighter never sees a line before a parser has finished with it:
mu.lines.stage({ id: 'friends', phase: 'highlight', run(l) { l.highlight(/\bBob\b/, { fg: 'gold' }); } });
mu.lines.stage({ id: 'links', phase: 'transform', run(l) { l.link(/look (\w+)/, { send: 'look $1' }); } });transform and route change game text: list them in muclient.contributes.linePhases. Colours are theme
tokens. A stage slower than 8 ms on 20 lines in a row is suspended for the session (the Installed card resumes it).
mu.lines.recent, search and history read what the client holds and what the backend logged.
Routing (1.14): mu.lines.route({ id, rules, deliver }) matches every non-echo line against the rules held in
one of your kind: 'json' settings and calls deliver(target, line, ctx) per matched target;
mu.lines.testRoutes(text, rules) and patternError(pattern) use the same matcher.
Held lines against rules (1.15): mu.lines.query(sid, { rules?, limit?, before? }) returns the lines the
client holds for a session, oldest first, as HeldLine ({ line: FeedLineView, targets, move }). The line is the
same FeedLineView a router's deliver gets, so its spans keep cls/style and ANSI colour survives (recent
gives theme tokens only). With rules, each line is matched as the route stage would and only lines with a target
come back (echo lines never match); without, every line comes back with targets: []. limit (default 500,
1..5000) counts returned lines from the newest end. Use it to back-fill a feed when the player adds or edits a rule:
for (const { line, targets } of mu.lines.query(sid, { rules: newRules })) for (const t of targets) store.push(t, line);Hover cards and the games directory (1.16): in the transform phase, line.tip(range, { title, body, action? })
puts a hover card on a range or every match. The text keeps its colour and gains a dotted underline; hovering,
tapping or focusing it opens a framed card (title in caps, the body, and [ LABEL ] when there is an action, which
sends action.send as a link does). Links are left alone; screen-reader mode draws no marks. mu.games.linked(worldId)
is the games-directory slug a world is linked to, watchLinked(worldId, cb) follows it (and fires again when the
listing's glossary changes), and await mu.games.lore(slug) is the listing's public lore glossary (LoreTip[], []
when there is none), cached by ETag:
const tips = await mu.games.lore(mu.games.linked(worldId) ?? '');
const byTerm = new Map(tips.flatMap((t) => t.terms.map((term) => [term, t] as const)));
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`\\b(?:${[...byTerm.keys()].map(escapeRe).join('|')})\\b`, 'g');
mu.lines.stage({ id: 'lore', phase: 'transform', run(line) {
for (const m of line.text.matchAll(re)) { const t = byTerm.get(m[0])!; line.tip({ start: m.index!, end: m.index! + m[0].length }, { title: t.title, body: t.blurb, action: { label: 'Read more', send: `help ${t.help}` } }); }
} });Input stages see each command before it is sent:
mu.input.stage({ id: 'speedwalk', phase: 'expand', order: 100, run(cmd) {
const m = /^(\d+)([nsew])$/.exec(cmd.text);
if (m) cmd.expand(Array(Number(m[1])).fill(m[2]));
} });
mu.input.stage({ id: 'quit', phase: 'guard', async run(cmd) {
if (cmd.text === 'quit' && !(await mu.ui.confirm({ title: 'Quit?' }))) cmd.swallow('Quit cancelled.'); // 1.12
} });The separator (;) and quick-rule aliases are expand stages, so 3n;look and an alias on each step work.
Completion: mu.input.completions({ id, provide: ({ word }) => [{ text: 'lantern', kind: 'item' }] }).
Macros: mu.macros.provide({ id, label, keys, run }).
Ask the game without showing the answer (declare send-commands):
const lines = await mu.sessions.request('@tickets', { start: /^=== YOUR/, until: /^\s*q: Quit/ });Requests queue per session. With key, only the client whose send went through captures; the others get null.
mu.sessions.send(text, { raw: true, echo: false }) sends literally, with no echo and no history entry, and
resolves 'sent' | 'duplicate' | 'refused'. mu.gmcp.seen('Client.Tickets') and
await mu.gmcp.whenSeen(pkg, { timeoutMs }) tell you whether the game speaks a package on this connection.
Surfaces (1.12)
Use the host's dialogs, menus and palette rather than your own: they follow the theme, trap focus, close on Esc, and go when your extension is disabled.
if (await mu.ui.confirm({ title: 'Delete note?', confirm: 'Delete', danger: true })) remove();
const name = await mu.ui.prompt({ title: 'Name', validate: (v) => (v ? null : 'required') }); // null when cancelled
const room = await mu.ui.pick({ title: 'Go to', filter: true, items: rooms.map((r) => ({ label: r.name, value: r.id })) });
const m = mu.ui.modal({ title: 'Map', width: 'lg', mount: (el) => draw(el) }); // m.close(), await m.closed
const safe = mu.ui.sanitize(html, 'inline'); // a DocumentFragment
const row = mu.ui.h('button', { class: mu.ui.css.cmd, onclick: go }, '› go'); // DOM without innerHTMLA dialog raised in a game event's handler opens only on the client that owns the session's effects; pass
everywhere: true for one the player must answer wherever they are.
Menus. mu.menus.add({ id, slot: 'main' | 'world' | 'tab' | 'panel:<id>', title, icon?, run }); main-menu
icons come from the glyph set. Terminal and own targets go through the context registry. Core renders the
line, word, link, selection, world, tab and panel kinds. An extension registers any other kind as
<extId>.<name> with a title and an optional schema; only it may mark elements with that kind, and the host
checks data against the schema on every menus.target call (a mismatch throws). Any extension can add entries
for a registered kind. Augment ContextKinds to type t.data:
declare module '@muclient/sdk' { interface ContextKinds { 'tickets.ticket': { id: number } } }
mu.menus.context({ id: 'look', target: 'word', title: (t) => `Look at ${t.word}`, run: (t) => mu.sessions.send(`look ${t.word}`, { sid: t.sid }) });
mu.menus.kind({ id: 'tickets.ticket', title: 'Ticket', schema: { type: 'object', required: ['id'], properties: { id: { type: 'integer' } } } });
mu.menus.context({ id: 'claim', target: 'tickets.ticket', title: (t) => `Claim #${t.data.id}`, run: (t) => claim(t.data.id) });
mu.menus.target(rowEl, { kind: 'tickets.ticket', sid, data: { id: 12 } }); // right-click and long-press open the menuThe 1.12 kinds channel, channel-message, scene-item, scene-exit and x-<name> still work through 1.x
and log one deprecation warning per extension. The first four are now channels.channel, channels.message,
scene.item and scene.exit, with the old fields under data.
Palette. mu.palette.provide({ mode: 'rooms', prefix: '@', title: 'Rooms', items: async (q) => [...] }) adds a
mode; mu.palette.verbs(sid, ['@stats']) adds verbs to the default list for that world.
Badges and notices. mu.panels.badge(id, { count: 3 }, sid) puts a count on the panel's tab, the session tab
and the world; badges are state and show on every client. mu.notify.mention({ sid, title, body, key }) raises a
mention once per key across the player's clients; mu.notify.alert({ title, sound: 'blip', key }) is a toast
with a sound. mu.ui.toast(title, body, { action: { label: 'undo', run }, group: 'tickets', timeoutMs }).
Files and network. mu.files.save({ name, data }), mu.files.open({ accept }), mu.net.fetch(url). Declare
files / network in muclient.capabilities.
Theme and access. mu.theme.watch((t) => …) fires now and on every change; mu.theme.register({ id, title,
tokens }) adds a theme to Settings → Visual (all 14 tokens; fg and fg-dim must reach 4.5:1). mu.a11y.announce(text)
speaks through the live regions; mu.a11y.region(el, { name }) joins the F6 cycle. mu.hud.mount({ id, slot:
'status', mount }) draws in a HUD slot.
Modules (1.12)
mu.panels.register({ id: 'tickets', title: 'Tickets', show: 'auto', role: 'staff', mount });
mu.gmcp.on('Client.Tickets.List', (d, meta) => { mu.panels.touch('tickets', meta.sid); render(d); });
mu.actions.define({ id: 'tickets.claim', label: 'Claim', group: 'Tickets', via: 'command', command: '@ticket/claim {id}' });
await mu.actions.run('tickets.claim', { id: '12' }, { sid, key: `claim:12` }); // 'sent' | 'duplicate' | 'handled' | 'hidden'show: 'auto'keeps the panel out of Views untiltouch; the player gets a "Show panel: off / auto / on" row per world.roleoffers the panel only whereprovideIdentitygives that role.- An action is a bindable command (
Tickets: Claim) with per-world settings for how it is sent. - Declare message contracts in
contributes.gmcp[].messages({ dir: 'in' | 'out', schema }): an inbound payload that does not match is dropped before your handler, with onesession.errorline a minute. ctx.exports((caller) => api)gives each calling extension its own API; what it registers withcaller.track(dispose)goes when the caller is disabled.- A world pack (
muclient.worlds: { hosts: ['example.org'] }) is suggested when the player adds a world at that host; itscontributes.compose/verbs/channelsReplyFormatbecome that world's defaults.
Dev loop: recordings, replay, headless tests
There are no new mu members. These are tools around the SDK (@runmu.sh/dev).
- Record: GMCP inspector → record → save .murec keeps a session's output, GMCP in and out, MSDP, and
on MCP worlds the MCP messages and negotiation. It is saved locally and redacted: the character name becomes
{char}, andChar.Login*and credential-like keys are dropped. - Test:
import { createHost } from '@runmu.sh/dev/test'runsactivateagainst a headlessmu. Feed ithost.gmcp(...)orawait host.play('session.murec'), then assert onhost.sent, widgets and models. The template'snpm testdoes exactly this. - Discover: GMCP packages and server-offered MCP packages that nothing on the client handles show as "This
game sends …" with the listings that declare them (
contributes.gmcp/contributes.mcp). The client asks the marketplace one package name at a time (GET /v1/extensions?gmcp=<pkg>), and sends no game text or world. Yourgmcp.on,gmcp.watchandmcp.onclaim a package while they are live.
Commands and styles (1.12, cleanup)
mu.commands.register({ id: 'notes.find', title: 'Find note', group: 'Notes', when: 'panel:notes', run });
mu.commands.run('layout.reset'); // a PUBLIC_COMMANDS id, or one an extension registered
mu.ui.style(`.ext-panel[data-ext="notes"] .row { color: var(--gold) }`);grouplists the command as ": Notes";when: 'panel:<id>'makes it available while that panel is open.mu.commands.runof any other core id warns once in your log in 1.x and throws from 2.0.- Your sheet sits in
@layer ext.<id>, under the host'smulayer, so scope your selectors to your panel. In dev, a rule that reaches host elements is logged. - A game's defaults (compose modes, verbs) belong in a world pack, not in core. The client bundles Underspire's (
clients/packs/underspire), which also shows how to write one.
