Build a mod interface
Choose native Aether UI or a sandbox app, then add panels, cards, settings, and actions without crossing the authority boundary.
Aether gives a mod two complete UI contracts. Pick one for each panel:
| Contract | You author | Aether owns | Use it when |
|---|---|---|---|
| Native view | a compiled, inert tree | components, layout behavior, theme, accessibility | your panel should look and behave like Aether |
| Sandbox app | a normal HTML/CSS/JS app in an iframe | the frame, title, disclosure, permissions, token stream | the interface needs its own visual identity or imperative rendering |
Custom pixels are not a permission. The install review and marketplace disclose that the mod uses a custom interface, but the GM cannot grant half of a panel. Actual powers such as reading documents, rolling dice, or posting cards remain capabilities the GM grants separately.
#Create the package
The default is a native panel:
aether create mod encounter-clock --title "Encounter Clock"Ask for a normal sandbox app explicitly:
aether create mod character-builder --ui app --title "Character Builder"Both templates use Vite. bun run dev serves a manifest at /mod.json; paste that URL into the
world's Mods dialog. Before publishing, run bun run build && bun run preview and install the
preview URL. Preview serves dist, which is the artifact that will ship.
#Author syntax: TypeScript first, TSX second
Native UI is a file format, not a component runtime. Plain TypeScript builders are the default because they make that boundary honest: every function runs at build time and the output is JSON. There is no React dependency, hook model, reconciliation, or tenant code imported by the host.
TSX is available as optional build-time syntax through @aether/sdk/native, and it compiles to the
same artifact. Use it if nested markup is easier for your team. Do not choose it because you expect
React semantics: components cannot keep state or run in a player's browser.
#A native panel
This is a complete panel with composition, local tab state, projected data, and a gesture. The
CharacterRow function disappears during the build. The collection receives only documents this
reader may see, and member cannot be referenced outside that collection.
import {
defineNativeView,
gesture,
item,
native,
query,
} from "@aether/sdk/native";
const member = item("member");
const CharacterRow = () =>
native.section({
title: member.text("name"),
children: [
native.grid({
columns: { compact: 1, wide: 2 },
children: [
native.metric({
label: "Armour class",
value: member.number("system.ac"),
shape: "shield",
}),
native.button({
label: "Roll initiative",
action: gesture.roll("1d20"),
}),
],
}),
],
});
export const partyView = defineNativeView({
surface: "panel",
root: native.tabs({
stateKey: "encounter-clock.tab",
defaultTab: "party",
children: [
native.tab({
id: "party",
label: "Party",
children: [
native.collection({
source: query.documents({ type: "srd.character", limit: 12 }),
item: "member",
empty: native.text({ children: "No visible characters." }),
children: [CharacterRow()],
}),
],
}),
native.tab({
id: "notes",
label: "Notes",
children: [native.text({ children: "Encounter notes go here." })],
}),
],
}),
});#The same panel in TSX
TSX changes the build-time authoring syntax, not the artifact or runtime. This is the same panel, including its component function, projected collection, host-owned tab state, and authoritative roll gesture:
/** @jsxImportSource @aether/sdk/native */
import {
Button,
Collection,
Grid,
Metric,
Section,
Tab,
Tabs,
Text,
defineNativeView,
gesture,
item,
query,
} from "@aether/sdk/native";
const member = item("member");
function CharacterRow() {
return (
<Section title={member.text("name")}>
<Grid columns={{ compact: 1, wide: 2 }}>
<Metric
label="Armour class"
value={member.number("system.ac")}
shape="shield"
/>
<Button label="Roll initiative" action={gesture.roll("1d20")} />
</Grid>
</Section>
);
}
export const partyView = defineNativeView({
surface: "panel",
root: (
<Tabs stateKey="encounter-clock.tab" defaultTab="party">
<Tab id="party" label="Party">
<Collection
source={query.documents({ type: "srd.character", limit: 12 })}
item="member"
empty={<Text>No visible characters.</Text>}
>
<CharacterRow />
</Collection>
</Tab>
<Tab id="notes" label="Notes">
<Text>Encounter notes go here.</Text>
</Tab>
</Tabs>
),
});The @jsxImportSource comment selects Aether's tiny build-time JSX transform. It does not import
React. CharacterRow() runs while the mod builds, and JSON.stringify(partyView) contains only the
validated native-view format.
Register the compiled artifact in the manifest. Native views do not request a styling capability. The semantic action gives the host a safe place to reopen the panel; it does not choose an exact menu position.
import { manifestSchema } from "@aether/sdk";
import { partyView } from "./view";
export default manifestSchema.parse({
id: "encounter-clock",
kind: "mod",
title: "Encounter Clock",
version: "0.1.0",
minSdkVersion: "0.1.0",
contributes: [
{
kind: "panel",
id: "encounter-clock.party",
title: "Party",
render: { kind: "native", view: partyView },
defaultLocation: "right",
singleton: true,
},
{
kind: "action",
id: "encounter-clock.open",
anchor: "view.actions",
label: "Open Encounter Clock",
icon: "Clock",
gesture: {
kind: "openPanel",
panelId: "encounter-clock.party",
},
},
],
});The same panel may dock, float, or pop out. Aether owns all three frames. Your tree is only the panel body, so it cannot cover the tab, close control, mod identity, or another panel.
#A manual tour for a sandbox app
Declare package tours in your manifest. In version 1, users start them manually from Help โ Tutorials. Tours cannot start automatically, target Aether-owned UI, reference panels from another package, or control their priority.
Each step needs localized text, a panel from your package, a stable anchor ID exposed by that panel, and an outcome that marks the step complete.
If a step waits for an event, use actionHint to tell the user what action will enable Continue.
import { manifestSchema } from "@aether/sdk";
export default manifestSchema.parse({
id: "encounter-clock",
kind: "mod",
title: "Encounter Clock",
version: "0.2.0",
minSdkVersion: "0.4.0",
contributes: [
{
kind: "panel",
id: "encounter-clock.party",
title: "Party",
render: { kind: "app", entry: "index.html" },
},
{
kind: "tour",
id: "party-basics",
revision: 1,
defaultLocale: "en",
title: {
en: "Party basics",
"pt-BR": "Noรงรตes do grupo",
es: "Conceptos del grupo",
},
launch: { kind: "manual" },
steps: [
{
id: "open-roll-options",
body: {
en: "Open the roll options for one visible character.",
"pt-BR": "Abra as opรงรตes de rolagem de um personagem visรญvel.",
es: "Abre las opciones de tirada de un personaje visible.",
},
actionHint: {
en: "Click the roll-options button in Party.",
"pt-BR": "Clique no botรฃo de opรงรตes de rolagem em Grupo.",
es: "Haz clic en el botรณn de opciones de tirada en Grupo.",
},
anchor: {
kind: "panel",
panelId: "encounter-clock.party",
anchorId: "ability-check",
},
placement: "left",
interaction: "perform",
missingAnchor: {
kind: "wait",
timeoutMs: 3000,
fallback: "abort",
},
completeWhen: {
kind: "event",
event: "roll-options.opened",
match: { anchorId: "ability-check" },
},
advance: "automatic",
},
],
},
],
});Inside the sandbox, register the element for the step's anchor. Aether tracks its position, visibility, scrolling, and removal. Unregister the element when it unmounts.
import { connectToHost } from "@aether/sdk/sandbox";
const aether = await connectToHost();
const button = document.querySelector<HTMLButtonElement>("#ability-check");
const openRollOptions = () =>
document.querySelector("#roll-options")?.removeAttribute("hidden");
if (button) {
const unregister = aether.tours.registerAnchor("ability-check", button);
button.addEventListener("click", () => {
openRollOptions();
aether.tours.signal("roll-options.opened", {
anchorId: "ability-check",
});
});
addEventListener("pagehide", unregister, { once: true });
}Report the outcome only after the action happens. Aether accepts it only for the active tour and current step, and ignores stale or duplicate signals.
Every tour must include strings for its default locale. Missing translations fall back to that locale. If the target panel is unavailable, Aether uses the fallback declared for that step.

#A system sheet
Sheets use the same native vocabulary as panels, with one additional input: the projected document being viewed. Reusing the vocabulary means score blocks, meters, tabs, fields, and collections keep the same behavior everywhere without turning a sheet into a generic stack of inputs.
import { defineNativeView, document, expr, native } from "@aether/sdk/native";
const actor = document("actor");
const modifier = (ability: string) =>
expr.floor(
expr.divide(
expr.subtract(actor.number(`system.abilities.${ability}`), 10),
2,
),
);
export const characterSheet = defineNativeView({
surface: "sheet",
root: native.tabs({
stateKey: "character.tab",
defaultTab: "actions",
children: [
native.tab({
id: "actions",
label: "Actions",
children: [
native.grid({
columns: { compact: 2, wide: 6 },
gap: "control",
children: ["str", "dex", "con", "int", "wis", "cha"].map(
(ability) =>
native.score({
key: ability,
label: ability.toUpperCase(),
value: modifier(ability),
detail: actor.number(`system.abilities.${ability}`),
format: "signed",
editableDetail: {
bind: actor.number(`system.abilities.${ability}`),
min: 1,
max: 30,
},
}),
),
}),
native.grid({
columns: { compact: 1, wide: 3 },
children: [
native.metric({
label: "AC",
value: actor.number("system.ac"),
shape: "shield",
}),
native.meter({
label: "Hit points",
value: actor.number("system.hp.value"),
max: actor.number("system.hp.max"),
shape: "vital",
tone: "health",
editable: true,
}),
native.metric({
label: "Speed",
value: actor.number("system.speed"),
suffix: "ft",
}),
],
}),
],
}),
native.tab({
id: "features",
label: "Features",
children: [
native.prose({ content: actor.richText("system.features") }),
],
}),
],
}),
});import { defineSystem } from "@aether/sdk";
import { characterSheet } from "./character-sheet";
import { characterSchema } from "./schema";
export default defineSystem({
manifest: {
id: "my-system",
kind: "system",
title: "My System",
version: "0.1.0",
minSdkVersion: "0.1.0",
},
setup() {
return {
documents: [characterSchema],
sheets: { "my-system.character": characterSheet },
};
},
});Fields patch through the normal document protocol. The server validates the document schema and the presser's edit authority. A hidden field is absent from a player's projected document, so a reference cannot reveal either its value or the fact that it exists.
#A chat card with authoritative buttons
Chat cards stay a dedicated, bounded format because cards from several mods share one stream. The body supports headings, paragraphs, lists, quotes, links, marks, document references, and inline action references. It does not accept HTML, CSS, arbitrary layout, or executable callbacks.
Declare the capability and the command that reaches your sandbox:
export const spellCardContributions = {
capabilities: ["chat.card", "dice.roll", "documents.read"],
contributes: [
{
kind: "chatCommand",
name: "/spell",
args: "<name>",
description: "Post a spell card.",
emits: { topic: "spell-cards.command" },
},
],
};Post inert data from the sandbox app or background entry:
import { connectToHost } from "@aether/sdk/sandbox";
async function main(): Promise<void> {
const client = await connectToHost();
client.subscribe("spell-cards.command", (payload) => {
const name = (payload as { args?: string }).args || "Fire Bolt";
void client.postCard(
{
source: { icon: "Wand" },
title: name,
subtitle: "Evocation cantrip",
rows: [
{ label: "Casting time", value: "1 action" },
{ label: "Range", value: "120 feet" },
],
body: [
{
t: "paragraph",
spans: [
{
t: "text",
text: "Make a ranged spell attack. ",
marks: ["italic"],
},
{
t: "link",
href: "https://example.com/rules",
text: "Read the rule",
},
],
},
],
actions: [
{
id: "attack",
label: "Attack roll",
variant: "primary",
audience: { kind: "anyone" },
effect: {
kind: "roll",
formula: "1d20+5",
label: `${name} attack`,
},
},
{
id: "damage",
label: "Damage",
audience: { kind: "anyone" },
effect: { kind: "roll", formula: "1d10", label: `${name} damage` },
},
],
},
`cast ${name}`,
);
});
}
void main();The card does not receive a live callback. A press sends the stored action id to the server. The server reloads the stored effect and checks the person who pressed it, including the action's audience and any document ownership. It then rolls or runs automation authoritatively.
#A settings page
Settings are a dedicated host-rendered form, not another free-layout tree. The declaration is the schema the server uses for defaults, ranges, options, scopes, and writes.
const settings = {
kind: "settings",
id: "spell-cards.preferences",
title: "Spell Cards",
groups: [
{
id: "rolls",
title: "Rolls",
description: "Defaults used by cards you post.",
fields: [
{
key: "attackBonus",
label: "Attack bonus",
scope: "user",
type: "number",
default: 5,
min: -20,
max: 30,
step: 1,
},
{
key: "showSource",
label: "Show source links",
scope: "world",
type: "boolean",
default: true,
help: "Only a GM may change a world-scoped value.",
},
],
},
],
} as const;The host renders the page under the mod's Settings control. Sandbox code reads only its own namespace; it cannot inspect another mod's values. User settings are private to that user. World settings are projected to members, and only the GM may write them.
const values = await client.settings.all();
const attackBonus = Number(values.attackBonus ?? 5);
// A user-scoped write. null restores the manifest default.
await client.settings.set("attackBonus", attackBonus + 1);#A menu or header action
Mods contribute meaning to a semantic anchor. They do not choose exact coordinates, ordering, width, colour, or whether a narrow layout uses a menu instead of a button.
const postSpellAction = {
kind: "action",
id: "spell-cards.post",
anchor: "document.actions",
label: "Post spell to chat",
icon: "Wand",
shortcut: "shift S",
appliesTo: { documentTypes: ["srd.spell"] },
audience: "anyone",
gesture: {
kind: "emit",
topic: "spell-cards.post-spell",
payload: {},
},
} as const;The host places this in a document header or overflow/context menu as space allows, under From mods. Shortcut collisions are refused centrally. When pressed, the server checks the action is installed, enabled, applicable, visible to this reader, and allowed for this audience. Only then does the sandbox receive the topic with the already-projected target.
The current anchors are document.actions, sheet.actions, library.item.actions,
view.actions, and world.actions. New host surfaces add anchors without granting pixel control.
#A fully custom panel
A sandbox panel is a normal web application. Use any framework you can bundle to static files, or
none. Aether never imports that bundle into the host process. The iframe has scripts but no shared
origin, popups, modal dialogs, forms, or top navigation; steady-state communication uses a private
MessagePort transferred during the handshake.
const builderPanel = {
kind: "panel",
id: "character-builder.app",
title: "Character Builder",
render: {
kind: "app",
entry: "index.html",
kitMajor: 1,
themeMajor: 1,
},
defaultLocation: "float",
} as const;The optional @aether/mod-ui package is framework-neutral custom elements plus CSS. It is bundled
from your own origin with your app; it does not import React or @aether/ui. The host streams
versioned theme tokens into the document. Token names in one major are additive and keep their
meaning, and every token has a fallback so an older host remains readable.
<main class="builder-shell">
<aether-card>
<aether-stack>
<h1>Choose an ancestry</h1>
<aether-tabs value="human">
<aether-tab value="human">Human</aether-tab>
<aether-tab value="elf">Elf</aether-tab>
</aether-tabs>
<aether-field label="Character name">
<input id="name" autocomplete="off" />
</aether-field>
<aether-meter label="Steps complete" value="2" max="5"></aether-meter>
<aether-button id="save" variant="primary">Save character</aether-button>
</aether-stack>
</aether-card>
</main>
<script type="module" src="/src/main.ts"></script>import { installModUi } from "@aether/mod-ui";
import "@aether/mod-ui/theme.css";
import { connectToHost } from "@aether/sdk/sandbox";
installModUi();
async function main(): Promise<void> {
const client = await connectToHost();
document.querySelector("#save")?.addEventListener("click", async () => {
const name = (document.querySelector("#name") as HTMLInputElement).value;
// Replace this example roll with a capability appropriate to your app.
try {
await client.roll("1d20", { source: "character-builder", name });
await client.toast.show({
title: "The roll was sent to the table.",
tone: "success",
});
} catch (error) {
await client.toast.show({
title: "The roll could not be sent.",
description: error instanceof Error ? error.message : String(error),
tone: "error",
});
}
});
}
void main();Your CSS may style anything inside the panel body, including a canvas or a complete visual system. It may never style Aether's frame, tab, title, menus, chat stream, map, or another mod. The host keeps a visible Custom interface strip around the body and owns loading, failure, and reload states.
#Where state lives
| State | Put it here | Example |
|---|---|---|
| ephemeral native UI | host state keyed by stateKey | selected tab |
| ephemeral app UI | your app memory or DOM | an open accordion |
| per-user preference | a user setting | compact card descriptions |
| world preference | a world setting | which source book to use |
| authoritative game state | a document patch or automation action | current hit points |
| deterministic rules computation | a system automation script | apply damage and conditions |
Do not treat iframe storage as game state. A panel may be closed, popped out, reloaded, or mounted in more than one client. Anything the table relies on belongs on the server-authoritative path.
#What is validated, and when
manifestSchemavalidates the authored contribution at build time.- The client fetches and validates
mod.jsonwithout running tenant code. - The GM reviews capabilities and the custom-interface disclosure.
- The server revalidates installed settings, actions, commands, and every authoritative effect.
- Native views query the reader's projected replica; sandbox reads return projected documents.
- Renderers stay tolerant of an unknown future node, but acceptance is strict for the format version and profile limits: at most 512 nodes, 16 levels, 256 KiB, and 200 collection rows.
A missing field is not undefined data for a clever expression to probe. Projection removes it
before the view or sandbox sees the document. Never encode a secret in presentation conditions,
labels, action availability, or collection shape.