Authoring

The manifest

Every field, what it is for, and the one that cannot change after you publish.

addon.json sits next to your entry file. The schema that validates it is the same module CI, the dev server, and the loader all use, so a manifest that passes pnpm validate cannot fail at install.

A minimal manifest

Seven required fields. Everything else is optional.

addon.json
{
  "id": "my-addon",
  "name": "My Addon",
  "version": "1.0.0",
  "apiVersion": 1,
  "author": "you",
  "description": "What it does, in one line.",
  "entry": "main.js"
}

Fields

Field Required Notes
id yes Lower-case kebab-case, and the same as your directory name. It is your storage namespace and your keybind scope, so it cannot change once published: a rename orphans every installed player's settings, keybinds and window position, and shows in Browse as a different addon installing alongside the old one. Get it right before anyone has it.
name yes What Browse shows and what the install confirmation repeats. Unlike the id, this is free to change.
version yes Semver. A marketplace serves one version per ref, so this is what an update compares against.
apiVersion yes The addon API major, currently 1. A loader that cannot honour it marks the addon incompatible and never evaluates it, rather than running it and failing somewhere unhelpful.
apiMinor no The smallest API minor carrying every member you use, currently up to 2. Optional, and leaving it out means 0. The major says which contract you were written against; this says how much of it had been built, because the surface only ever grows within a major. Declare it and an older loader refuses you by name and is never even offered the update; leave it low and feature-detect with woc.apiMinor if you would rather degrade than be refused. Each member in the API reference says which minor introduced it.
author yes Shown on every row and on the install confirmation.
description yes One line. It is what Browse shows and what the install confirmation repeats, so write it for someone deciding whether to trust you.
entry yes A relative path inside your directory, usually main.js. It must not traverse outside, and the file is evaluated as a function body: no exports, no registration call, woc already in scope.
preview no { file, alt }. A PNG in your own directory, usually preview.png, and one sentence saying what it shows. The manager draws it as a thumbnail in Browse and full size on the install confirmation, and the catalog page builds its own derivatives from the same file, so a screenshot is committed once and lives beside the addon rather than in a list somewhere else. Optional: no preview means a row and a card without one, never a failed build.
data no JSON files in your own directory, up to eight, each under half a megabyte. The loader fetches them at install, caches them beside your code, and hands you the parsed value through woc.data("items.json"). This is what a large table should be: a file that regenerates as a file, rather than a region rewritten inside your source. There is deliberately no base URL, so nothing in your addon performs the request. Needs apiMinor 2.
companions no Up to four other addon ids yours works better with. It GATES nothing: it never blocks your install, installs anything for the player, orders anything, or stops you starting. What the manager draws is the state, which is the half a description cannot carry: installed and running, installed but switched off, available in Browse, or offered by no source they have. Each state carries the one action that fits it, and every one of those is a jump into a control that already existed. Bare ids rather than fully-qualified ones, because the same addon installed from a fork is the companion you meant. Nothing on the woc surface changes, so do not raise apiMinor for it, and nothing checks that the id exists: a companion may live on a marketplace this repository has never heard of.
companionReasons no One short sentence per companion id, up to 140 characters, saying what that addon ADDS to yours. Keyed by an id you also list in companions, and a key that is not there fails validation rather than being quietly ignored. This is the sentence that otherwise ends up in your description, where it is read before the player knows the companion exists and can say nothing about whether they have it. The manager hangs it on the companion name and repeats it on the install confirmation when a player follows one. A separate key rather than a richer companions, so an older loader drops it and still reads your manifest: a marketplace index is parsed as one array, and one entry an old loader chokes on takes the whole source down.
homepage no A URL shown on the addon row.
tags no Up to six, same shape as an id. They become the filter controls in Browse, which is why they are bounded and why two authors cannot publish Combat and combat as different tags.
gameVersion no A semver range, for example ">=0.31.0". Outside it the addon is marked incompatible rather than left to break, which is the difference between a clear message and a mystery.
channels no Restrict to some of live, pbe, pbe2. Omit it unless your addon genuinely depends on something only one deployment has.
permissions no What you use, out of net.read, world.read, ui, sound, keys, storage. Shown one line each on the install confirmation. This is a disclosure, not a boundary: see below.
keybinds no { id, label, default } each. You can only bind an id you declared, and the manager renders the editor and the conflict warnings for you.
settings no boolean, number (with optional min and max), string, or select (with options). The manager renders the form; you read woc.settings and hear about changes through woc.onSettingsChange.

The id is the one you cannot take back

Everything else on that table is free to change between versions. The id is not, and it is worth understanding why rather than just obeying it.

The id is your storage namespace, your keybind scope, and half of every fully-qualified id the loader uses to tell your addon apart from someone else's with the same name. Renaming a published addon is therefore not a rename. Every player who installed it keeps their settings, keybinds and window position filed under the old name, where nothing will ever read them again, and the new name arrives in Browse looking like a different addon that installs alongside the old one.

combat-meter was called dps-meter until a healing tab made the name wrong. That rename was free, and it was free only because nothing had been released yet.

permissions is a disclosure, not a boundary

This is the most important sentence on this page, and it is easy to read the field the wrong way round.

Addon code runs in the page realm with the page's globals in scope. A manifest that declares nothing is not thereby prevented from doing anything. The list you write is what you are telling the player your addon is for, and the loader shows it on the install confirmation next to a sentence saying exactly that.

So declare what you use and nothing more, because the value of the list is that it is honest. A permission list presented with nothing beside it reads as a sandbox, and there is not one.

Settings and keybinds are rendered for you

Declare them and the manager builds the form, the keybind editor, and the conflict warnings. You never draw any of that.

{
  "settings": [
    { "id": "max-rows", "type": "number", "label": "Rows to show", "default": 10, "min": 3, "max": 40 },
    { "id": "show-detail", "type": "boolean", "label": "Show per-hit detail", "default": true }
  ],
  "keybinds": [{ "id": "toggle", "label": "Show or hide the meter", "default": "Alt+KeyD" }]
}

Both are hydrated before your first line runs, so woc.settings['max-rows'] is there immediately rather than arriving later. Changes reach you through woc.onSettingsChange, and a rebind moves your live binding for you.

What you declare here is what you get, so your addon does not check. The loader coerces every stored value against this declaration before your code sees it: a number is a finite number clamped into the min and max above, a boolean is a boolean, a select is one of the options you still offer, and anything that is none of those falls back to the default on this line. woc.settings['max-rows'] is therefore a number between 3 and 40 on your first line and on every line after it, and a typeof guard with a fallback beside it can never fire. The API page has the worked version, and the count of how many addons wrote that guard anyway.

You can only bind an id you declared. That is what makes the editor able to list your keys before your addon has run.

A label is read in two places, not one. The manager puts it beside the control, and an addon published through the official marketplace gets its own page on this site where every setting and every default binding is printed from this same declaration. So a label is player-facing text rather than a note to yourself: write it as the sentence a checkbox deserves, and the page and the pane cannot disagree about what the setting does.

Shipping a table beside your code

An addon is one file, but an addon directory is not. entry names your code; data names JSON files next to it, and the loader fetches them at install and hands them back parsed.

{
  "data": ["items.json", "zones.json"],
  "apiMinor": 2
}
const items = await woc.data('items.json');

Up to eight files, each under half a megabyte, each ending in .json because woc.data parses what it reads. pnpm validate checks every declared file exists, parses, and fits, so a table that would fail a player's install fails CI instead.

Declare apiMinor: 2 when you use it. An older loader drops a manifest key it has never heard of, so without that line it would install you happily and then run you with a woc.data that rejects.

Naming an addon yours works better with

{
  "companions": ["lorebind"],
  "companionReasons": {
    "lorebind": "publishes item names and prices, which is what puts a name and a worth on every row here"
  }
}

Up to four bare addon ids, and one short sentence each saying what that addon ADDS to yours. The manager draws them under your description with the name the player would recognise, the state they are in, and the one thing to do about it: installed and running, installed but switched off with an Enable beside it, available in Browse with a Get, or offered by no source they have and nothing to press.

Write the reason here rather than in your description. A description is read before the player knows the companion exists and can say nothing about whether they have it; this is read next to the answer to both. The sentence hangs on the companion's name, and a player who follows a Get sees it again on the install confirmation, which is the screen where it decides something.

companionReasons is keyed by an id you also list in companions, and a key that is not there fails validation rather than being quietly dropped: two keys describing one relationship is the shape that drifts, so the tie between them is enforced. It is a separate key rather than a richer companions on purpose. A marketplace index is parsed as one array of manifests, so one entry an older loader cannot read takes the WHOLE source down for everyone still on that loader; an unrecognised key is dropped instead, which is what lets a manifest carrying reasons still install on a loader that has never heard of them.

It still gates nothing. It installs nothing on its own, orders nothing, and stops nothing from starting; every action it offers is a jump into a control that already existed, and an install still goes through the same confirmation any other install does. Bare ids rather than fully-qualified ones, because the same addon installed from a fork is still the companion you meant. Nothing on the woc surface changes, so do not raise apiMinor for it, and nothing checks the id exists: a companion may legitimately live on a marketplace this repository has never heard of.

Checking it

pnpm validate

Runs the real schema over every addons/*/addon.json. The dev server runs the same reader on every request, so a manifest saved mid-session is visible on the next refresh and the dev index cannot diverge from what CI would accept.