The module manifest
module.json sits at the root of your bundle. The loader reads it synchronously, before
anything else about your module runs.
Unknown top-level keys are rejected, not ignored. A misspelled key is a loud failure rather than a silently-inert setting — which is the right trade when the alternative is a module that boots and mysteriously does half its job.
A complete manifest
Section titled “A complete manifest”{ "id": "uo", "name": "Ultima Online", "version": "1.0.0", "coreApi": "^1.0.0", "server": "server/index.js", "client": { "entry": "client/dist/entry.js" }, "schema": "server/db/schema.sql", "purge": "server/db/purge.sql", "mounts": { "public": ["/shard", "/atlas"], "admin": ["/shard", "/uo-link"], "player": ["/shard"] }, "extensions": ["admin.users.detail"], "capabilities": ["shard", "atlas", "market"]}The keys
Section titled “The keys”| Key | Required | Meaning |
|---|---|---|
id |
yes | ^[a-z][a-z0-9-]{1,31}$. The directory name, the installed_modules key, the URL segment, and the client registry key — all at once. Must equal the directory it was read from. |
name |
yes | Human label for the admin Modules screen |
version |
yes | Semver. Recorded on install; shown on failure |
coreApi |
yes | Semver range, checked against core’s MODULE_API_VERSION |
server |
no | Server entry point, relative to the module root. Absent means a client-only module |
client.entry |
no | The prebuilt ESM chunk, in a subdirectory — the directory it sits in is what gets served. Absent means a server-only module; present-but-empty is rejected, because it claims a client half and delivers none |
schema |
no | Idempotent SQL fragment, replayed every boot |
purge |
no | Destructive teardown. Required if schema is present |
mounts |
no | Declared route prefixes per tier |
extensions |
no | Core extension slots this module mounts into |
capabilities |
no | Opaque strings published to clients for feature detection |
mounts is a claim, not a description
Section titled “mounts is a claim, not a description”The loader compares your declaration against what your module actually registers, and rejects a mismatch in either direction. Declaring a prefix you never mount fails; mounting one you never declared fails too.
Prefixes are validated against ^/[a-z0-9][a-z0-9-]*$, and the keys must match what you
register exactly.
capabilities is for feature detection
Section titled “capabilities is for feature detection”Opaque strings, published by GET /api/v1/public/modules — and only while the module is
started. Clients like the SPA and the Android app read them to decide what to show.
They are not permissions and not mount prefixes. Keep them stable: something outside your repository is branching on them.
coreApi and what a range means
Section titled “coreApi and what a range means”Core exports a single semver string, currently 1.6.0. Your range is checked at boot, before your code is required.
A minor bump adds members without removing any or changing a signature, so ^1.3.0
keeps resolving against 1.6.0 — which is exactly why module-uo still declares ^1.3.0
and runs fine.
Use a caret range against the oldest core you actually support and test against. Pinning exactly buys nothing and strands you on the next additive release.
Schema and purge
Section titled “Schema and purge”schema runs on every boot, so it must be idempotent — CREATE TABLE IF NOT EXISTS,
and additive migrations written so a replay is harmless. Table names must be namespaced or
allowlisted; the loader checks.
purge is required whenever schema is present, because a module that can create tables
must offer a way to remove them. It is only ever run by an explicit purge — never as part
of an uninstall.
Remember where purge.sql lives: inside the
directory an uninstall deletes. Write it to be run by someone who no longer remembers what
your module created.
The full specification
Section titled “The full specification”MODULE_API.md
§2.1 is normative for the manifest, and §2.6 for the schema fragments.