# Build your first module

> Build a small NXM module from scratch, with one business object, a list, a dialog, a page of its own and sample data. Then package it, install it in a tenant and ship an update with a data migration.

Brandt & Söhne lends laptops, monitors and phones to its staff. Today the list lives in a spreadsheet that nobody trusts: who has the MacBook, and when is it due back? In this tutorial you build **Leihgeräte** (loan equipment), an NXM module that gives the company its own business object for devices, a list with filters, a dialog to lend a device, a page with all returns that are due, and tools for the AI Assistant. At the end you ship version 1.1.0 and watch nexxus migrate the stored data.

You write five files, pack them into one `.nxm` file and upload it. Everything the module shows comes from these files. No code runs inside nexxus.

<Flow
  steps={[
    { icon: 'pen-line', title: 'Write', text: 'module.json, a changelog, two HTML views, sample data.' },
    { icon: 'hammer', title: 'Build', text: 'The packaging script turns the folder into a .nxm file.' },
    { icon: 'upload', title: 'Upload', text: 'nexxus checks the package and shows what it brings.', tone: 'cyan' },
    { icon: 'package-check', title: 'Install', text: 'You agree, assign roles and the module is in the menu.', tone: 'lavender' },
    { icon: 'rocket', title: 'Update', text: 'Version 1.1.0 renames a value and adds a field.' },
  ]}
  caption="The whole tutorial at a glance. Every step after Write happens without a restart of nexxus."
/>

## Before you start

- **An administrator account** on a nexxus instance. Only administrators upload and install packages.
- **An instance that accepts your package.** The package you build here is unsigned. Instances that run with the Spring profile `development`, `local` or `demo` accept unsigned packages; elsewhere an administrator of the tenant switches on <Ui>Allow unsigned packages</Ui> on the tab <Ui>Publishers</Ui>, or you sign the package. Both are explained in [Package, install and update](/modules/publish/).
- **Node.js** to run the packaging script `module-package.mjs`. It ships with the nexxus sources under `backend/scripts/` and has no dependencies. Copy it next to your package folder.
- **A text editor.**

## Plan the module

Before you write a line, decide four things. They end up in the manifest and are hard to change later.

| Decision | Choice in this tutorial | Why |
|---|---|---|
| Key | `acme.leihgeraete` | `<publisher>.<name>`, lower case. The key never changes. All permission keys, tool names and routes derive from it. |
| Area | `projekte` | Business area of the module. `projekte` shows under **Organisation** in the left bar. |
| Business objects | one entity, `geraet` (device) | Each entity becomes its own list and record pages. |
| Who may do what | roles `verwaltung` (manage and lend) and `einsicht` (read only) | Roles are created at install. Nobody holds them until an administrator assigns them. |

## Create the folder

One folder per version. The folder name must be the version number, otherwise the script warns `Hinweis: Der Ordner heißt nicht wie die Version 1.0.0.`

<FileTree>
- acme.leihgeraete/
  - 1.0.0/
    - module.json the manifest
    - CHANGELOG.md
    - ui/
      - ausleihen.html dialog to lend a device
      - faellig.html page with due returns
    - demo/
      - geraet.json sample data, installed on request
</FileTree>

## Write the manifest

`module.json` describes everything: the entity and its fields, permissions, roles, views, menu entries, actions and what the AI Assistant may do. Create it with this content.

```json title="acme.leihgeraete/1.0.0/module.json"
{
  "key": "acme.leihgeraete",
  "kind": "package",
  "publisher": "acme",
  "name": {"de": "Leihgeräte", "en": "Loan equipment"},
  "description": {"de": "Geräte, die das Team ausleiht: wer hat was, bis wann.", "en": "Equipment the team borrows: who has what, until when."},
  "icon": "devices",
  "area": "projekte",
  "version": "1.0.0",
  "platform": ">=0.2.0 <1.0.0",
  "permissions": [
    {"key": "verleihen", "label": {"de": "Geräte verleihen", "en": "Lend equipment"},
     "description": {"de": "Darf Geräte ausleihen und zurücknehmen.", "en": "May lend equipment and take it back."}}
  ],
  "roles": [
    {"key": "verwaltung", "label": {"de": "Geräteverwaltung", "en": "Equipment management"},
     "permissions": ["geraet.read", "geraet.write", "verleihen"]},
    {"key": "einsicht", "label": {"de": "Einsicht", "en": "View"}, "permissions": ["geraet.read"]}
  ],
  "entities": [
    {
      "key": "geraet",
      "label": {"de": "Gerät", "en": "Device"},
      "labelPlural": {"de": "Geräte", "en": "Devices"},
      "icon": "devices",
      "titleField": "bezeichnung",
      "searchFields": ["bezeichnung", "inventarnummer"],
      "fields": [
        {"key": "inventarnummer", "type": "string", "sequence": "GER-{n:4}", "label": {"de": "Inventarnummer", "en": "Inventory no."}},
        {"key": "bezeichnung", "type": "string", "required": true, "maxLength": 120, "label": {"de": "Bezeichnung", "en": "Name"}},
        {"key": "kategorie", "type": "enum", "indexed": true, "default": "laptop", "label": {"de": "Kategorie", "en": "Category"},
         "values": [
           {"key": "laptop", "label": {"de": "Laptop", "en": "Laptop"}},
           {"key": "monitor", "label": {"de": "Monitor", "en": "Monitor"}},
           {"key": "telefon", "label": {"de": "Telefon", "en": "Phone"}},
           {"key": "sonstiges", "label": {"de": "Sonstiges", "en": "Other"}}]},
        {"key": "status", "type": "enum", "indexed": true, "readonly": true, "default": "verfuegbar", "label": {"de": "Status", "en": "Status"},
         "values": [
           {"key": "verfuegbar", "label": {"de": "Verfügbar", "en": "Available"}},
           {"key": "verliehen", "label": {"de": "Verliehen", "en": "On loan"}}]},
        {"key": "ausgeliehen_an", "type": "ref", "target": "core:user", "label": {"de": "Ausgeliehen an", "en": "Lent to"}},
        {"key": "rueckgabe_am", "type": "date", "indexed": true, "label": {"de": "Rückgabe am", "en": "Due back"}},
        {"key": "notiz", "type": "text", "label": {"de": "Notiz", "en": "Note"}}
      ]
    }
  ],
  "views": [
    {"key": "geraet.list", "type": "list", "entity": "geraet", "label": {"de": "Geräte", "en": "Devices"},
     "columns": [{"field": "inventarnummer"}, {"field": "bezeichnung"}, {"field": "kategorie"},
                 {"field": "status", "display": "badge"}, {"field": "ausgeliehen_an"}, {"field": "rueckgabe_am"}],
     "filters": ["kategorie", "status", "ausgeliehen_an"], "sort": {"field": "inventarnummer", "dir": "asc"}, "create": true},
    {"key": "geraet.record", "type": "record", "entity": "geraet", "label": {"de": "Daten", "en": "Data"},
     "sections": [
       {"key": "geraet", "label": {"de": "Gerät", "en": "Device"}, "fields": ["inventarnummer", "bezeichnung", "kategorie", "status"]},
       {"key": "ausleihe", "label": {"de": "Ausleihe", "en": "Loan"}, "fields": ["ausgeliehen_an", "rueckgabe_am", "notiz"]}],
     "actions": ["ausleihen-dialog", "zurueckgeben"]},
    {"key": "ausleihen-dialog", "type": "custom", "slot": "record.action", "target": "geraet",
     "html": "ui/ausleihen.html", "label": {"de": "Ausleihen", "en": "Lend"}, "icon": "assignment_ind",
     "when": {"field": "status", "in": ["verfuegbar"]}},
    {"key": "faellig", "type": "custom", "slot": "page", "html": "ui/faellig.html",
     "label": {"de": "Fällige Rückgaben", "en": "Due returns"}}
  ],
  "menu": [
    {"key": "geraete", "label": {"de": "Geräte", "en": "Devices"}, "icon": "devices", "view": "geraet.list", "order": 1},
    {"key": "faellig", "label": {"de": "Fällige Rückgaben", "en": "Due returns"}, "icon": "assignment_late", "view": "faellig", "order": 2}
  ],
  "actions": [
    {"key": "ausleihen", "entity": "geraet", "type": "update", "label": {"de": "Ausleihen", "en": "Lend"}, "icon": "assignment_ind",
     "permission": "verleihen", "when": {"field": "status", "in": ["verfuegbar"]},
     "set": {"status": "verliehen", "ausgeliehen_an": "{{payload.ausgeliehen_an}}", "rueckgabe_am": "{{payload.rueckgabe_am}}"}},
    {"key": "zurueckgeben", "entity": "geraet", "type": "update", "label": {"de": "Zurückgeben", "en": "Return"}, "icon": "assignment_return",
     "permission": "verleihen", "when": {"field": "status", "in": ["verliehen"]},
     "confirm": {"de": "Gerät als zurückgegeben buchen?", "en": "Book the device as returned?"},
     "set": {"status": "verfuegbar", "ausgeliehen_an": null, "rueckgabe_am": null}, "aiTool": true}
  ],
  "aiTools": {"entities": ["geraet"]},
  "assistant": {
    "instructions": {"de": "Du hilfst bei den Leihgeräten des Teams. Den Status ändern nur die Aktionen „Ausleihen“ und „Zurückgeben“.",
                     "en": "You help with the team's loan equipment. Only the actions Lend and Return change the status."},
    "prompts": [
      {"view": "geraet.list", "label": {"de": "Wer hat welches Gerät?", "en": "Who has which device?"},
       "prompt": {"de": "Liste alle verliehenen Geräte mit Person und Rückgabedatum.", "en": "List every device on loan with person and due date."}},
      {"view": "faellig", "label": {"de": "Erinnerung entwerfen", "en": "Draft a reminder"},
       "prompt": {"de": "Entwirf eine freundliche Erinnerung an alle, deren Rückgabe fällig ist.", "en": "Draft a friendly reminder to everyone whose return is due."}}
    ]
  }
}
```

### Why the manifest looks like this

A few decisions in this file are worth copying into your own modules:

- **`status` is `readonly`.** Nobody can set a device to "on loan" by editing the form. Only the two actions change it, and each action checks its permission `verleihen` and its `when` condition on the server. A form that sends the value back unchanged still saves.
- **Lending needs input, returning does not.** The action `ausleihen` needs a person and a date, so it runs from the dialog view `ausleihen-dialog`, which collects them and calls the action. The action `zurueckgeben` needs nothing, so it sits directly in the record's action bar, with a confirmation question first. The record view's `actions` list decides exactly what the bar shows.
- **`inventarnummer` is a `sequence`.** When a device is created without a number, nexxus assigns the next one: `GER-0001`, `GER-0002` and so on. A sequence field is unique and read-only by definition, and you can sort by it.
- **Every filter and sort field is indexed.** `kategorie`, `status` and `rueckgabe_am` carry `"indexed": true`. References such as `ausgeliehen_an` are indexed automatically. Filtering or sorting by a field that is not indexed is refused when you upload.
- **The AI Assistant can work with devices.** `aiTools.entities` gives it tools to search and read `geraet` and the actions to create and update one (`acme.leihgeraete:geraet.create`, `acme.leihgeraete:geraet.update`). `"aiTool": true` on `zurueckgeben` adds the action `acme.leihgeraete:zurueckgeben`. The AI Assistant never runs an action itself: it proposes it as a card in the chat, and the person's click runs it.

The complete list of fields and options is in [Package structure](/modules/structure/).

## Add the changelog

Every package carries a changelog with a heading for its own version. nexxus shows it on the package page and in the update dialog.

```markdown title="acme.leihgeraete/1.0.0/CHANGELOG.md"
# Changelog: Leihgeräte

## [1.0.0] - 2026-10-12

- Geräte mit Inventarnummer, Kategorie und Status; ausleihen und zurückgeben mit Rückgabedatum.
- Seite „Fällige Rückgaben“.
```

## Write the dialog view

`ui/ausleihen.html` is a custom view in the slot `record.action`. nexxus shows it as a dialog on a device's page and draws the dialog header itself. The view only draws the fields and its button. It runs in a sandbox and talks to nexxus through the view SDK, `window.nx`, which is already loaded when the first script of the view runs.

```html title="acme.leihgeraete/1.0.0/ui/ausleihen.html"
<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
  <div class="nx-stack">
    <label class="nx-field">
      <span class="nx-field__label" id="l-person"></span>
      <select class="nx-select" id="person"></select>
    </label>
    <label class="nx-field">
      <span class="nx-field__label" id="l-datum"></span>
      <input class="nx-input" type="date" id="datum">
    </label>
    <div class="nx-row"><span class="nx-spacer"></span><button class="nx-btn nx-btn--primary" id="ok"></button></div>
  </div>
  <script>
    (async () => {
      const ctx = await nx.ready();
      const $ = id => document.getElementById(id);
      $('l-person').textContent = nx.t({de: 'Ausleihen an', en: 'Lend to'});
      $('l-datum').textContent = nx.t({de: 'Rückgabe am', en: 'Due back'});
      $('ok').textContent = nx.t({de: 'Ausleihen', en: 'Lend'});
      for (const person of await nx.lookup('core:user', {})) {   // first 20; pass {q} to search
        const option = document.createElement('option');
        option.value = String(person.id);
        option.textContent = person.label;
        $('person').append(option);
      }
      $('ok').onclick = async () => {
        try {
          const {record} = await nx.invoke('ausleihen', {
            recordId: ctx.record.id,
            payload: {ausgeliehen_an: Number($('person').value), rueckgabe_am: $('datum').value || null},
          });
          nx.dialog.close(record);
        } catch (e) {
          nx.ui.toast(e.message, {kind: 'error'});
        }
      };
    })();
  </script>
</body>
</html>
```

What it does:

- `nx.ready()` returns the context. In a dialog on a record, `ctx.record` holds the entity and the id of the device.
- `nx.lookup('core:user', {})` lists people of the tenant (the first 20 without a search term; service and system accounts are left out). A real dialog would search with `{q}` as the user types.
- `nx.invoke('ausleihen', ...)` runs the action as the person who clicked. nexxus checks the permission and the `when` condition again. If something is refused, the error message shows as a toast.
- `nx.dialog.close(record)` closes the dialog. The record page reads the device again and shows the new state.
- Labels come from `nx.t`, so the view speaks the person's language. Text goes in with `textContent`, never `innerHTML`.
- The `nx-*` classes come from the UI kit that nexxus injects into every view, so the dialog looks like the rest of the app, in light and dark mode.

All functions of the SDK are listed in [Views and the view SDK](/modules/views/).

## Write the page view

`ui/faellig.html` is a page of its own (slot `page`). The menu entry `faellig` points to it. It searches devices on loan whose return date is today or earlier.

```html title="acme.leihgeraete/1.0.0/ui/faellig.html"
<!doctype html>
<html>
<head><meta charset="utf-8"><style>body { padding: 16px; }</style></head>
<body>
  <p class="nx-muted" id="hint"></p>
  <div class="nx-list" id="list"></div>
  <div class="nx-empty" id="empty" hidden><div class="nx-empty__title" id="empty-title"></div></div>
  <script>
    (async () => {
      await nx.ready();
      const $ = id => document.getElementById(id);
      $('hint').textContent = nx.t({de: 'Verliehene Geräte, deren Rückgabe heute oder früher fällig ist.', en: 'Devices on loan due back today or earlier.'});
      $('empty-title').textContent = nx.t({de: 'Alles zurück.', en: 'Everything is back.'});
      const today = new Date().toLocaleDateString('sv-SE');   // YYYY-MM-DD in local time
      const {items} = await nx.records.search('geraet', {
        filter: {status: 'verliehen', rueckgabe_am: {to: today}},
        sort: 'rueckgabe_am', size: 100,
      });
      $('empty').hidden = items.length > 0;
      for (const g of items) {
        const item = document.createElement('button');
        item.className = 'nx-list__item';
        item.textContent = `${g.title} · ${g.refs?.ausgeliehen_an?.label ?? '-'} · ${g.data.rueckgabe_am}`;
        item.onclick = () => nx.nav.open({entity: 'geraet', id: g.id});
        $('list').append(item);
      }
      nx.assistant.setContext({
        summary: nx.t({de: `${items.length} fällige Rückgaben`, en: `${items.length} due returns`}),
        records: items.map(g => ({entity: 'geraet', id: g.id})),
      });
    })();
  </script>
</body>
</html>
```

The search filters on two indexed fields. A date range with only `to` includes that day. `g.title` is the device's `bezeichnung` (its `titleField`), and `g.refs` carries the display name of each referenced person. `nx.nav.open` opens the device's page. `nx.assistant.setContext` tells the AI Assistant which devices the person is looking at, so a question like "remind them" refers to the right people.

## Add sample data

Sample data is optional. When the administrator ticks <Ui>Create sample data</Ui> during the install, nexxus creates these rows. Placeholders fill in values that only exist in the target tenant.

```json title="acme.leihgeraete/1.0.0/demo/geraet.json"
[
  {"key": "thinkpad", "data": {"bezeichnung": "ThinkPad T14", "kategorie": "laptop", "status": "verfuegbar"}},
  {"key": "macbook", "data": {"bezeichnung": "MacBook Air", "kategorie": "laptop", "status": "verliehen",
                              "ausgeliehen_an": {"$currentUser": true}, "rueckgabe_am": {"$date": "-2wd"}}},
  {"key": "monitor", "data": {"bezeichnung": "Monitor 27 Zoll", "kategorie": "monitor", "status": "verliehen",
                              "ausgeliehen_an": {"$currentUser": true}, "rueckgabe_am": {"$date": "+1w"}}}
]
```

- `{"$currentUser": true}` becomes the administrator who installs.
- `{"$date": "-2wd"}` is two working days before the install day, `"+1w"` one week after it. The MacBook is therefore always overdue when someone tries the module, and the monitor never is.
- Sample data may set `status` although it is read-only: data files are written by the installer, not by a person.

All placeholders are described in [Data and upgrades](/modules/data-and-upgrades/).

## Build the package

<Steps>

1. Open a terminal in the folder that contains `acme.leihgeraete/` and `module-package.mjs`.

2. Build the archive:

   ```sh
   node module-package.mjs build acme.leihgeraete/1.0.0
   ```

   The script checks that `module.json` has `kind`, `key` and `version` and that `CHANGELOG.md` has the heading `## [1.0.0]`. It writes `acme.leihgeraete-1.0.0.nxm` with a file `MANIFEST.sha256` that lists the hash of every file, and prints the file name, `5 Dateien`, the size and `unsigniert`.

3. Optionally read the archive back:

   ```sh
   node module-package.mjs verify acme.leihgeraete-1.0.0.nxm
   ```

   This checks the hashes and the changelog heading. It does not check the manifest's content: nexxus does that when you upload.

</Steps>

The same folder always produces the same archive, byte for byte.

## Upload and install

<Steps>

1. Sign in as an administrator and open <MenuPath to="Administration > Operations > Modules" />. Switch to the tab <Ui>Packages</Ui>.

2. Click <Ui>Upload package (.nxm)</Ui> and choose `acme.leihgeraete-1.0.0.nxm`. nexxus uploads and checks the file. Then the toast <Ui>Loan equipment 1.0.0 uploaded.</Ui> appears and the package page opens. The English UI shows the English texts of the manifest (`name.en`, `label.en`), the German UI the German ones. The Polish UI shows the English ones too, because this manifest has no `pl` texts.

3. Read the card <Ui>What the package brings</Ui>. For this package it lists:

   | Row | Content |
   |---|---|
   | <Ui>Business objects</Ui> | Device |
   | <Ui>Menu entries</Ui> | Devices, Due returns |
   | <Ui>Views</Ui> | Devices · List, Data · Data tab, Lend · Dialog on a record, Due returns · Page |
   | <Ui>Roles</Ui> | Equipment management, View, each with its permissions |
   | <Ui>Data outside the package</Ui> | none |
   | <Ui>External services</Ui> | none |
   | <Ui>Tools for the assistant</Ui> | `nx_acme_leihgeraete_geraet_search`, `_get`, `acme.leihgeraete:geraet.create`, `.update` and `acme.leihgeraete:zurueckgeben` |
   | <Ui>Permissions</Ui> | Read Devices (`acme.leihgeraete.geraet.read`), Edit Devices (`acme.leihgeraete.geraet.write`), Lend equipment (`acme.leihgeraete.verleihen`) |

   Because the package is unsigned, a yellow note says <Ui>Not signed. Check where it comes from before you install it.</Ui>

   <Screenshot src={shot_nxm_30} url="/modules/pakete/acme.leihgeraete?version=1.0.0" alt={"The package page of the tutorial package after the upload"} caption={"What the tutorial package brings, read from its manifest."} />

4. Click <Ui>Install</Ui> in the action bar. The dialog <Ui>Install Loan equipment</Ui> opens. Under <Ui>Your consent</Ui> it says <Ui>The package uses no data outside its own.</Ui>, since the package asks for no core data and no external service.

5. Under <Ui>Assign roles</Ui>, pick the people for each role and confirm each picker with its green check button. At Brandt & Söhne, Tom Keller from IT gets <Ui>Equipment management</Ui> and Mia Schulz gets <Ui>View</Ui>. You can leave both empty and assign the roles later in the role editor.

6. Tick <Ui>Create sample data</Ui> and click <Ui>Install</Ui>. The toast <Ui>Loan equipment is installed.</Ui> confirms it.

   <Screenshot src={shot_nxm_31} url="/modules/pakete/acme.leihgeraete?version=1.0.0" alt={"Installing the tutorial package with roles and sample data"} caption={"No data outside its own, so there is nothing to consent to."} />

</Steps>

## Try it out

<Steps>

1. Open the left bar's area **Organisation**. Below the built-in sections there is a new section <Ui>Loan equipment</Ui> with the entries <Ui>Devices</Ui> and <Ui>Due returns</Ui>. No reload is needed.

   <Screenshot src={shot_nxm_32} url="/x/acme.leihgeraete/geraet" alt={"The new menu section of the module"} caption={"The module appears in the menu without a reload."} />

2. Click <Ui>Devices</Ui> (route `/x/acme.leihgeraete/geraet`). The list shows the three sample devices with numbers from `GER-0001` up, the search field <Ui>Search Devices</Ui>, the menus <Ui>Filter</Ui> (category, status, person) and <Ui>Sort</Ui>, and the button <Ui>New Device</Ui>.

   <Screenshot src={shot_nxm_24} url="/x/acme.leihgeraete/geraet" alt={"The device list of the tutorial module"} caption={"The list comes from the manifest's list view."} />

3. Click <Ui>New Device</Ui>. The tab <Ui>Data</Ui> shows the sections <Ui>Device</Ui> and <Ui>Loan</Ui>. The inventory number reads <Ui>Assigned on creation</Ui>. Enter a name and click <Ui>Create</Ui>.

4. On the new device, click <Ui>Lend</Ui> in the action bar. The dialog from `ausleihen.html` opens with the fields <Ui>Lend to</Ui> and <Ui>Due back</Ui>. Pick a person, for example Mia Schulz, and a date, and click <Ui>Lend</Ui>. The dialog closes and the status shows <Ui>On loan</Ui>. The action bar now offers <Ui>Return</Ui> instead.

   <Screenshot src={shot_nxm_33} url="/x/acme.leihgeraete/geraet/50/daten" alt={"Lending a device in the module's dialog"} caption={"The dialog is a custom view; nexxus draws its header."} />

5. Click <Ui>Return</Ui>. nexxus asks <Ui>Book the device as returned?</Ui>. Click <Ui>Run</Ui>. The toast reads <Ui>"Return" done.</Ui>

6. Open <Ui>Due returns</Ui> (route `/x/acme.leihgeraete/p/faellig`). The sample MacBook is listed, because its return date lies two working days back.

   <Screenshot src={shot_nxm_34} url="/x/acme.leihgeraete/p/faellig" alt={"The page with due returns"} caption={"A custom page that searches devices whose return is due."} />

7. Open the AI Assistant on the device list. The example prompt <Ui>Who has which device?</Ui> appears as a chip. Ask the AI Assistant to book the MacBook as returned: before anything changes, the chat shows an action card with the three checks <Ui>Input</Ui>, <Ui>State</Ui> and <Ui>Permission</Ui> and the button <Ui>Execute</Ui>. The server ran the checks already; a red one names what is missing, and the button stays off. Action names come from the package and stay German. Depending on how you ask, the AI Assistant may pick another action of the module, for example changing the device; the card then shows that one. Ask for two changes at once and one card carries both, with <Ui>Execute all 2</Ui>.

</Steps>

People without the administrator role see the module only when they hold one of its roles: <Ui>Equipment management</Ui> for everything, <Ui>View</Ui> for reading. See [Roles](/setup/roles/).

## Ship version 1.1.0

Brandt & Söhne wants two changes: "Telefon" should read "Smartphone", and every device needs a location. A released version never changes, so you create version 1.1.0 next to it.

<Steps>

1. Copy the folder `acme.leihgeraete/1.0.0` to `acme.leihgeraete/1.1.0`.

2. In the copy's `module.json`, set `"version": "1.1.0"`. In the field `kategorie`, replace the value `telefon` with:

   ```json
   {"key": "smartphone", "label": {"de": "Smartphone", "en": "Smartphone"}}
   ```

3. Add a new field after `status` and add `"standort"` to the `fields` of the section `geraet` in `geraet.record`:

   ```json
   {"key": "standort", "type": "enum", "indexed": true, "default": "buero", "label": {"de": "Standort", "en": "Location"},
    "values": [
      {"key": "buero", "label": {"de": "Büro", "en": "Office"}},
      {"key": "homeoffice", "label": {"de": "Homeoffice", "en": "Home office"}}]}
   ```

4. Tell nexxus how to move the stored data. Add at the top level of `module.json`:

   ```json
   "migrations": {
     "1.1.0": {
       "pre":  [{"op": "renameValue", "entity": "geraet", "field": "kategorie", "from": "telefon", "to": "smartphone"}],
       "post": [{"op": "setDefault", "entity": "geraet", "field": "standort", "value": "buero"}]
     }
   }
   ```

   `pre` steps run before nexxus switches to the new version, `post` steps after it. `renameValue` turns every stored `telefon` into `smartphone`. `setDefault` gives every existing device the location `buero`.

5. Add a section on top of `CHANGELOG.md`:

   ```markdown
   ## [1.1.0] - 2026-10-19

   - Kategorie „Telefon“ heißt jetzt „Smartphone“; bestehende Geräte werden umgestellt.
   - Neues Feld Standort (Büro oder Homeoffice); bestehende Geräte stehen im Büro.
   ```

6. Build it with `node module-package.mjs build acme.leihgeraete/1.1.0` and upload `acme.leihgeraete-1.1.0.nxm` on the tab <Ui>Packages</Ui>.

7. The package page shows the chip <Ui>Update to 1.1.0</Ui>. Click <Ui>Upgrade to 1.1.0</Ui>. The dialog <Ui>Upgrade Loan equipment</Ui> shows <Ui>From 1.0.0 to 1.1.0</Ui>, the new changelog section under <Ui>What changes</Ui> and, under <Ui>Data changes</Ui>, a dry run on today's data: `renameValue` on `geraet.kategorie` (<Ui>before the switch</Ui>) and `setDefault` on `geraet.standort` (<Ui>after the switch</Ui>), each with the number of records it changes. With only the sample data, that is 0 and 3 (plus the device you created). There is no section <Ui>New consent</Ui>, because 1.1.0 asks for no new data access.

   <Screenshot src={shot_nxm_35} url="/modules/pakete/acme.leihgeraete?version=1.1.0" alt={"Updating the tutorial package to 1.1.0 with two migration steps"} caption={"The dry run counts the records each step will change."} />

8. Click <Ui>Upgrade</Ui>. The toast reads <Ui>Loan equipment is upgraded to 1.1.0.</Ui>

</Steps>

Not every change is allowed in a minor version. Had you made `standort` required without a default, the update dialog would refuse with the server message `Das neue Pflichtfeld 'geraet.standort' hat keinen Standardwert.` (the new required field has no default). The rules are listed in [Data and upgrades](/modules/data-and-upgrades/).

## What happens behind the scenes

**On upload**, nexxus unpacks the archive, validates `module.json` against the package schema and then against the rules a schema cannot express: every column, filter, section and action names a field that exists, menu entries point to lists or pages, roles name existing permissions, files named in the manifest are in the archive. All problems are reported at once. Then it checks `MANIFEST.sha256` and the signature. The package is stored for your tenant only.

**On install**, in one transaction, nexxus:

- creates the installation with the state <Ui>Active</Ui> and records your consent,
- creates one access role per manifest role, named after the German names of module and role: `Leihgeräte – Geräteverwaltung` and `Leihgeräte – Einsicht`, and assigns the people you picked,
- creates a company-wide skill for the AI Assistant from `assistant.instructions`, named `Leihgeräte: Arbeitsweise` (way of working),
- writes the sample data, resolving placeholders and assigning inventory numbers.

After the commit the menu, the routes and the AI Assistant's tools are there for everyone with the right permissions. The permissions also appear in the role editor, with the German labels the server builds: „Geräte lesen“, „Geräte bearbeiten“ and „Geräte verleihen“ (read, edit and lend devices).

**At runtime**, every list, form and action goes through the same server checks, whether a person clicks, a view calls the SDK or the AI Assistant uses a tool. The views run in a sandbox without network access and without access to the page around them.

**On update**, the `pre` steps, the switch to 1.1.0 and the `post` steps run in one transaction. If one step fails, everything rolls back and the module stays on 1.0.0. Roles that existed in 1.0.0 receive permission keys that are new in 1.1.0. Permissions an administrator removed stay removed.

## When something goes wrong

When an upload is refused, the catalogue page shows <Ui>"acme.leihgeraete-1.0.0.nxm" was not accepted.</Ui> with the server's reason and one line per problem. The server writes these messages in German. The ones below are those you meet most often while building.

| Message | Cause and fix |
|---|---|
| `module.json menu.faellig: nur Listen und Seiten (slot page) gehören ins Menü` | A menu entry points to a view that is neither a `list` nor a `custom` view with slot `page`. Point it to such a view. |
| `module.json views.geraet.list: Filter 'notiz' ist nicht indexed` | You filter or sort by a field without `"indexed": true`. Add the flag. `text` and `json` fields cannot be indexed. |
| `module.json views.geraet.list: sort 'bezeichnung' ist nicht indexed` | Same for the list's `sort`. |
| `module.json entities.geraet: searchFields 'rueckgabe_am' muss Text sein` | Search fields must be `string`, `text` or `enum`. |
| `CHANGELOG.md: keine Überschrift '## [1.0.0]' für die Version aus module.json` | The changelog has no heading for the manifest's version. Add `## [1.0.0]`. The script stops earlier with `CHANGELOG.md: Überschrift "## [1.0.0]" fehlt.` |
| `module.json: area 'lager' ist kein Menübereich (...)` | `area` must be one of the business areas listed in the message. |
| `Das Archiv enthält kein module.json.` | `module.json` is neither at the top of the archive nor in a single top folder. Build with the script or zip the version folder itself. |
| `Das Paket ist nicht signiert. Unsignierte Pakete sind auf dieser Plattform nicht zugelassen.` | The instance refuses unsigned packages. Sign the package, see [Package, install and update](/modules/publish/). |
| `acme.leihgeraete 1.0.0 gibt es schon mit anderem Inhalt; eine neue Fassung braucht eine neue Versionsnummer.` | You changed files of a version that was already uploaded. Released versions are immutable: raise the version number in `module.json` and the changelog, with a new version folder or, in a package folder released by git tags, with a new tag. Uploading the identical file again is fine. |

Errors while using the module:

- Mia Schulz, who holds only the role <Ui>View</Ui>, opens the dialog <Ui>Lend</Ui> and gets the toast `Keine Berechtigung: Ihnen fehlt das Recht 'acme.leihgeraete.verleihen'.` (no permission). Custom views are shown to everyone who may read the entity, and they cannot see the person's rights yet. The server refuses the action. Give Mia Schulz the role <Ui>Equipment management</Ui>, or accept that readers see the button.
- The menu section does not appear: the person holds none of the module's roles, or the module is deactivated. Administrators always see it while the module is active. Switching the business area `projekte` off does not hide it.

## Next steps

<CardGrid>
  <LinkCard title="Package structure" description="Every manifest field, field type, view type and action option." href="/modules/structure/" />
  <LinkCard title="Views and the view SDK" description="All SDK functions, theming and the sandbox rules." href="/modules/views/" />
  <LinkCard title="Package, install and update" description="Signing, the catalogue, updates and uninstalling." href="/modules/publish/" />
</CardGrid>