MX for AI Agents
If you are an AI coding assistant writing mx, read this page first. Most mistakes come from one habit: pattern-matching mx onto React, Svelte, or Vue. mx is a DOM reconciler, not a framework - it has no JSX, no hooks, no virtual DOM, no signals, and no build step. The API is four globals and a handful of rules that fit in a system prompt.
The block below is the distilled, paste-ready version. Drop it into an agent's system prompt verbatim. Everything in it is verified against the 765-byte engine source.
Ships in the package too
The npm package @tltdsh/mx bundles an AGENTS.md with the same rules, so an agent editing an mx project picks them up locally. Read it at cdn.jsdelivr.net/npm/@tltdsh/mx/AGENTS.md, and see What mx Is Not for every React/Vue/Svelte feature that is deliberately absent and what replaces it.
Paste-Ready System Prompt
One copyable block. It covers the four globals, define semantics, the mx vs dom decision, $state merge rules, attributes, conditionals, keyed rows, and the hard don'ts.
// ── MX ENGINE: RULES FOR WRITING CODE ──
// MX is a ~765-byte DOM reconciler. It is NOT React/Vue/Svelte. There is
// no JSX, no hooks, no virtual DOM, no signals, no build step. Four globals
// live on window; you import nothing:
// mx.tag(attrs?, ...children) -> a description ARRAY (cheap), for render()
// dom.tag(attrs?, ...children) -> a real HTMLElement you can hold & mutate
// define(name, spec) -> register a component
// components -> the component registry object
//
// DECISION RULE (mx vs dom):
// Need a handle to call .$() / read .value / keep across renders? -> dom.tag()
// Just handing output to render() to reconcile? -> mx.tag()
// When unsure, use mx.
// ── COMPONENTS ──
define('user-card', {
// $ runs on mount and on every this.$() or parent .$(). It receives the
// MERGED $state object. Destructure props with defaults. Return ONE mx
// description, OR an array of children.
$({ name = '', active = false }) {
return [
mx.span({ class: 'name' }, name),
active && mx.span({ class: 'badge' }, 'online')
];
}
});
container.render(mx.userCard({ name: 'Ada', active: true }));
// ── STATE ($state) ──
// this.$({ key: val }) does Object.assign($state, {key:val}) then re-runs $.
// A parent's el.$({...}) merges the same way. Because the merge runs BEFORE
// your $ function, THE PARENT ALWAYS WINS any key it re-passes (controlled
// components). Keys you don't pass survive untouched.
// this.$({ count: 5 }) // correct: merge + re-render
// this.$state.count = 5 // WRONG: no merge, no re-render
// For state that must survive parent re-passes, seed a private this._x once.
// ── ATTRIBUTES (native DOM names, lowercase - NOT React) ──
mx.button({ onclick: handler }); // events: lowercase, set as property
mx.div({ class: 'active' }); // class - NOT className
mx.label({ for: 'email' }); // for - NOT htmlFor
mx.input({ '.value': text }); // '.name' sets a JS property directly
mx.button({ disabled: true }); // true -> setAttribute('disabled','true')
mx.button({ disabled: null }); // null / false / undefined -> removed
mx.div({ style: 'width:' + pct + '%' }); // style is a STRING, never an object
// ── CONDITIONAL RENDER ──
// render() skips false / null / undefined, but renders 0 as a text node "0".
!!list.length && mx.ul(...list.map(x => mx.li(x.name))); // !! guards the 0 leak
// Arrays from .map() MUST be spread with ... - an unspread array stringifies
// to "a,b,c" text. (Returning a bare array from $ is fine; $ spreads for you.)
// ── KEYED, STATEFUL ROWS (identity match) ──
// Real dom() nodes match by identity, so mx physically moves them and their
// internal state travels with them. Positional mx() rows would leave typed
// input / checkbox state at the old index after a sort.
this._rows ||= new Map;
for (let [id] of this._rows)
if (!items.find(i => i.id === id)) this._rows.delete(id);
for (let it of items) {
let row = this._rows.get(it.id) || this._rows.set(it.id, dom.userRow()).get(it.id);
row.$({ data: it });
}
container.render(...items.map(i => this._rows.get(i.id)));
// ── HARD DON'TS ──
// - No onClick / className / htmlFor / tabIndex / JSX. Lowercase DOM names only.
// - No PascalCase tags: mx.UserCard() -> "-user-card" (invalid). Use mx.userCard().
// - Never mutate this.$state directly - always this.$({...}).
// - Never mutate a prop array in place: use [...items].sort(), not items.sort().
// - Never addEventListener inside $() (stacks each render). Assign this.onclick = fn.
// - clearInterval(this._interval) at the TOP of $() before re-arming a timer.
// - Hoist static option/config arrays OUT of $(); don't re-allocate them per render.
// - Don't read rendered text (el.textContent) to drive logic; read $state / data.
// - this.$({}) with an empty object is a code smell - pass the actual change.Mistakes AI Agents Actually Make
Each pair is a real failure mode, with the wrong version first. The examples are deliberately generic - user tables, settings forms, dashboards.
React-isms
The single biggest source of broken mx. React attribute and event names fail silently - the handler never fires, the CSS selector never matches, the label never associates.
// ✗ React names - nothing here works in mx
mx.button({ onClick: save }, 'Save');
mx.div({ className: 'card' });
mx.label({ htmlFor: 'email' }, 'Email');
mx.input({ tabIndex: 0 });
// ✓ Native DOM names, lowercase
mx.button({ onclick: save }, 'Save');
mx.div({ class: 'card' });
mx.label({ for: 'email' }, 'Email');
mx.input({ tabindex: 0 });There is also no JSX. mx.tag(attrs, ...children) is the only element syntax; style is a string, not an object.
// ✗ JSX / object style - not a thing
return <div className="row">{name}</div>;
mx.div({ style: { width: pct + '%' } }); // -> style="[object Object]"
// ✓ Function calls, string style
return mx.div({ class: 'row' }, name);
mx.div({ style: 'width:' + pct + '%' });PascalCase tags
Tag names are camelCase-to-kebab-case converted with an unanchored /[A-Z]/g. A leading capital produces a leading hyphen - an invalid custom element name.
// ✗ PascalCase -> "-user-card" (invalid element)
mx.UserCard();
define('UserCard', { $() {} });
// ✓ camelCase call site, kebab or camelCase in define (both convert)
mx.userCard();
define('user-card', { $() {} }); // define('userCard', ...) also worksThe falsy-0 text-node leak
render() skips false, null, and undefined, but 0 is valid content and renders as the text "0". Any number on the left of && leaks.
// ✗ When the array is empty, a bare "0" appears in the DOM
users.length && mx.div({ class: 'list' }, ...rows);
count && mx.span('Unread: ' + count);
// ✓ Coerce to boolean with !!
!!users.length && mx.div({ class: 'list' }, ...rows);
!!count && mx.span('Unread: ' + count);Mutating $state directly
Assigning to $state skips the merge and never re-renders. The declarative flow is always this.$({...}).
// ✗ Silent no-op - no merge, no render
this.$state.selected = id;
// ✓ Go through $()
this.$({ selected: id });Unspread .map() arrays
A plain array from .map() is not an mx description, a DOM node, a string, or a number - it falls to the text branch and stringifies.
// ✗ Renders literal "Alice,Bob,Carol"
mx.ul(rows.map(r => mx.li(r.name)));
// ✓ Spread so each <li> is a real child
mx.ul(...rows.map(r => mx.li(r.name)));
Hallucinated legacy syntax
mx has never used Svelte's $: reactive label or a $: function component method. The component method is plain shorthand $(props) {}.
// ✗ Invented syntax - not mx
define('foo', { $: function(props) {} });
$: total = a + b;
// ✓ Method shorthand; derive values as plain locals inside $
define('foo', { $(props) {} });
$({ a = 0, b = 0 }) { let total = a + b; /* ... */ }Interval stacking across re-renders
Every $() call runs the whole function again. An un-cleared setInterval stacks a new timer each render.
// ✗ One extra timer per re-render
$({ live = false }) {
if (live) this._timer = setInterval(refresh, 1000);
}
// ✓ Clear at the TOP, before any conditional
$({ live = false }) {
clearInterval(this._timer);
if (live) this._timer = setInterval(refresh, 1000);
}Static arrays re-allocated inside $()
Constant option lists, column configs, and lookup tables do not belong inside $() - a fresh array is built on every render, and passing a new identity downstream defeats any memoization.
// ✗ New array every render
define('status-select', {
$({ value }) {
let options = ['open', 'pending', 'closed']; // re-allocated each call
return mx.select({ '.value': value },
...options.map(o => mx.option({ value: o }, o)));
}
});
// ✓ Hoist constants to module scope
let STATUSES = ['open', 'pending', 'closed'];
define('status-select', {
$({ value }) {
return mx.select({ '.value': value },
...STATUSES.map(o => mx.option({ value: o }, o)));
}
});Mutating prop arrays in place
Props point at the parent's data. .sort(), .reverse(), .splice() mutate it - a side effect that reaches outside the component. Copy first.
// ✗ Sorts the parent's array
$({ rows = [] }) {
rows.sort((a, b) => a.name.localeCompare(b.name));
}
// ✓ Copy, then sort the copy
$({ rows = [] }) {
let sorted = [...rows].sort((a, b) => a.name.localeCompare(b.name));
}Positional reuse of stateful rows
mx reconciles mx() children left-to-right by tag name, not by key. Reorder a list of positional rows and the DOM nodes stay put while their content updates - so a half-typed input or a checked box stays at the old index. Use real dom() nodes in a keyed Map; they match by identity and physically move.
// ✗ Positional: the typed note stays at row 0 after a sort
container.render(...rows.map(r => mx.div(
mx.span(r.name),
mx.input({ placeholder: 'note...' }) // this input never moves
)));
// ✓ Keyed dom() rows move by identity, state intact
this._rowMap ||= new Map;
for (let [id] of this._rowMap)
if (!rows.find(r => r.id === id)) this._rowMap.delete(id);
for (let r of rows) {
let row = this._rowMap.get(r.id) || this._rowMap.set(r.id, dom.userRow()).get(r.id);
row.$({ data: r });
}
container.render(...rows.map(r => this._rowMap.get(r.id)));The empty this.$({}) smell
An empty this.$({}) merges nothing and re-runs $ with unchanged state. It almost always means the component is mutating this._x as a hidden side effect and using the empty call to force a paint. Make the change part of state instead.
// ✗ Hidden data flow - what changed?
$() {
this._items.push(newRow);
this.$({});
}
// ✓ The change IS the state update
$({ items = [] }) {
return mx.button({ onclick: _ => this.$({ items: [...items, newRow] }) }, 'Add');
}Driving logic off rendered text
Never read a value back out of the DOM (el.textContent, an input's displayed value) to decide what to do next. The rendered text is an output of state; treat state and data-* attributes as the source of truth.
// ✗ Parsing your own output back into logic
let count = Number(this.qs('.count').textContent);
if (count > 0) this.$({ hasItems: true });
// ✓ Read the state (or a data attribute) directly
if (this.$state.items.length) this.$({ hasItems: true });Verify Your Output
Before returning mx code, self-check against this list. Every item maps to a mistake above.
Checklist
- Events are lowercase (
onclick), attributes are DOM names (class, for), no className / htmlFor / JSX. - Every tag is camelCase or kebab-case - no leading capital.
.map() output passed as children is spread with ....- Any number on the left of
&& is guarded with !!. - State changes go through
this.$({...}) - never this.$state.x = ..., never an empty this.$({}). - Prop arrays are copied before sort/filter/reverse (
[...arr]). clearInterval / clearTimeout sits at the top of $(); no addEventListener inside $().- Constant arrays/objects are hoisted out of
$(). - Stateful list rows use a keyed
dom() Map, not positional mx() rows. - You need a handle (calling
.$() later)? It is a dom.tag(), not an mx.tag(). - Logic reads from
$state / data, not from rendered text.
Full detail: Cheat Sheet, Gotchas, API Reference, and What mx Is Not.