# Package structure

> The reference for an NXM package. Which files a .nxm archive contains, every field of the manifest module.json with its type and rules, the keys nexxus derives from it, and how nexxus checks a package before it accepts it.

An NXM package is a folder with one manifest, a changelog and a few optional files, packed as a ZIP archive with the extension `.nxm`. Almost everything a module is lives in the manifest `module.json`: its business objects, screens, menu, actions, roles and what it may access. nexxus reads that file and builds the module from it. This page describes every file and every field. For a guided walk through a complete example, start with [Build your first module](/modules/first-module/).

## The files of a package

<FileTree>
- acme.besucher/1.0.0/
  - module.json the manifest (required)
  - CHANGELOG.md one section per version (required)
  - ui/
    - empfang.html one file per custom view
  - templates/
    - brief.html a letter that a mail action fills and sends as PDF
  - data/
    - besuch.json records every installation gets
  - demo/
    - besuch.json sample records, only on request
    - files/
      - hausordnung.pdf a document the sample records refer to
  - MANIFEST.sha256 written by the packaging script
  - SIGNATURE written by the packaging script when you sign
</FileTree>

| File | Required | What it holds |
|---|---|---|
| `module.json` | yes | The manifest, described below. |
| `CHANGELOG.md` | yes | Keep-a-Changelog format. It must contain a heading `## [<version>]` for the version in `module.json`, for example `## [1.0.0] - 2026-10-12`. nexxus shows the sections to administrators before an install or update. |
| `ui/<name>.html` | for custom views | One self-contained HTML file per custom view, with inline CSS and JavaScript, at most 2 MB. See [Views and the view SDK](/modules/views/). |
| `templates/<name>.html` | for a mail action with PDF | A static HTML template that nexxus fills and renders as PDF, at most 512 KB. No scripts, and it loads nothing: see [Templates](#templates). |
| `data/<entity>.json` | no | Records every installation gets, for example a catalogue of requirements. |
| `demo/<entity>.json` | no | Sample records, created only when the administrator asks for sample data at the first install. |
| `demo/files/<name>` | no | Files the sample records refer to, imported into Files. |
| `MANIFEST.sha256` | no | One line `<sha256>  <path>` per file, sorted by path. If present, it must list every other file with the right hash and nothing else. |
| `SIGNATURE` | no | `{"keyId": "...", "alg": "Ed25519", "sig": "..."}`, an Ed25519 signature over `MANIFEST.sha256`. |

The packaging script writes `MANIFEST.sha256` and `SIGNATURE` for you. How the data files work, including references between records and dates relative to the install day, is described in [Data and upgrades](/modules/data-and-upgrades/). Signing and uploading are covered in [Package, install and update](/modules/publish/).

A version that has been uploaded never changes. A fix, even a typo in a view, is a new version number in a new folder.

### Templates

A template in `templates/` is the layout of a document that nexxus fills with the values of a record and turns into a PDF, today the letter of a [mail action](#mail-actions). The file name has letters, digits, `-` and `_`, there are no subfolders, and a template has at most 512 KB.

The PDF service would load whatever a template points to. So a template carries no scripts and loads nothing, and nexxus refuses it at upload otherwise: no `<script>`, `<noscript>`, `<link>`, `<iframe>`, `<frame>`, `<object>`, `<embed>` or `<applet>`, no event attributes such as `onclick`, no `javascript:` addresses and no CSS `@import`. Every address in `src`, `href` and the other URL attributes and in CSS `url()` and `image-set()` must be a `data:` URL or a jump mark such as `#betreff`; links may also be `mailto:` and `tel:`. Images go in as `data:` URLs, fonts are system fonts, and styles stand in a `<style>` element of the template.

## A minimal manifest

The smallest useful module has one entity, one list and one menu entry. This visitor log for the reception desk is complete and valid:

```json
{
  "key": "acme.besucher",
  "kind": "package",
  "publisher": "acme",
  "name": {"de": "Besucherbuch", "en": "Visitor log"},
  "description": {"de": "Wer war wann bei uns zu Besuch und warum.", "en": "Who visited us, when and why."},
  "icon": "badge",
  "area": "projekte",
  "version": "1.0.0",
  "platform": ">=0.2.0 <1.0.0",
  "roles": [
    {"key": "empfang", "label": {"de": "Empfang", "en": "Reception"}, "permissions": ["besuch.read", "besuch.write"]}
  ],
  "entities": [
    {
      "key": "besuch",
      "label": {"de": "Besuch", "en": "Visit"},
      "labelPlural": {"de": "Besuche", "en": "Visits"},
      "titleField": "name",
      "searchFields": ["name"],
      "fields": [
        {"key": "name", "type": "string", "required": true, "label": {"de": "Name", "en": "Name"}},
        {"key": "firma", "type": "ref", "target": "core:partner", "label": {"de": "Firma", "en": "Company"}},
        {"key": "datum", "type": "date", "required": true, "indexed": true, "label": {"de": "Datum", "en": "Date"}},
        {"key": "zweck", "type": "enum", "indexed": true, "default": "termin", "label": {"de": "Zweck", "en": "Purpose"},
         "values": [
           {"key": "termin", "label": {"de": "Termin", "en": "Meeting"}},
           {"key": "lieferung", "label": {"de": "Lieferung", "en": "Delivery"}},
           {"key": "wartung", "label": {"de": "Wartung", "en": "Maintenance"}}]}
      ]
    }
  ],
  "views": [
    {"key": "besuch.list", "type": "list", "entity": "besuch", "label": {"de": "Besuche", "en": "Visits"},
     "columns": ["datum", "name", "firma", "zweck"], "filters": ["zweck", "datum"],
     "sort": {"field": "datum", "dir": "desc"}, "create": true}
  ],
  "menu": [
    {"key": "besuche", "label": {"de": "Besuche", "en": "Visits"}, "icon": "badge", "view": "besuch.list", "order": 1}
  ]
}
```

Installed, this gives the tenant a menu section "Visitor log" under **Organisation** with the entry "Visits". The list has a search box, filters for purpose and date, a button to create a visit, and a record page with a <Ui>Data</Ui> tab built from the four fields. The role "Reception" may read and write visits. The record page needs no `record` view: without one, nexxus shows every field.

## Top-level fields

Text that people read is always an object with a German and an English version, `{"de": "...", "en": "..."}`, and optionally a Polish one: `{"de": "...", "en": "...", "pl": "..."}`. `name` and `description` need German and English; everywhere else either of the two is enough. A Polish text alone never counts as one of them. Each reader gets the text in their own language, else in English, else in German, so a Polish reader of a package without Polish texts sees the English ones. Other keys are not allowed in these objects, and unknown fields anywhere in the manifest are refused.

| Field | Type | Required | Meaning | Example |
|---|---|---|---|---|
| `key` | string | yes | The module's name, `<publisher>.<name>`, lower case, digits and `-` allowed. Everything else is derived from it. | `"acme.besucher"` |
| `kind` | string | yes | Always `"package"`. | `"package"` |
| `publisher` | string | yes | Must equal the part of `key` before the dot. | `"acme"` |
| `name` | text, de and en | yes | Shown in the menu, page titles and the catalogue. | `{"de": "Besucherbuch", "en": "Visitor log"}` |
| `description` | text, de and en | yes | Shown in the catalogue. | |
| `icon` | string | no | A Material Symbols icon name. | `"badge"` |
| `area` | string | yes | The business area the module belongs to (table below). It places the module, it does not switch it. | `"projekte"` |
| `version` | string | yes | `major.minor.patch`, without a suffix. | `"1.0.0"` |
| `platform` | string | no | The nexxus versions the package runs on, as a version range. | `">=0.2.0 <1.0.0"` |
| `requires` | object | no | Built-in modules this one needs, each with a version range. Never another package. | `{"partners": ">=1.0.0"}` |
| `permissions` | list | no | Extra permissions besides read and write per entity. | see [Permissions and roles](#permissions-and-roles) |
| `roles` | list | no | Roles nexxus creates at install. | |
| `scopes` | list of strings | no | Data outside the package that actions and services may use. Needs the administrator's consent. | `["core:partner:read"]` |
| `entities` | list | no | The business objects. | see [Entities](#entities) |
| `views` | list | no | Lists, record pages and custom views. | see [Views](#views) |
| `menu` | list | no | Menu entries. | |
| `actions` | list | no | Workflow steps on records. | |
| `services` | list | no | Module services outside nexxus. | |
| `settings` | list | no | Settings of an installation, edited by administrators. | |
| `aiTools` | object | no | Which entities and actions the AI Assistant gets tools for. | |
| `assistant` | object | no | Instructions, example prompts, avatars, skills and AI buttons for the AI Assistant. | |
| `migrations` | object | no | Steps that change stored records during an update. | |
| `portal` | object | no | The public side of the package for customers without an account. | see [Customer portal](/modules/portal/) |
| `calendar` | list | no | Records with a date that the nexxus calendar shows. | see [Calendar sources](/modules/calendar/) |
| `dashboards` | list | no | Up to three start page templates, from nexxus 0.14.0. | see [Start page templates](#start-page-templates-dashboards) |

Version ranges follow the npm notation: comparisons separated by spaces must all hold (`>=0.2.0 <1.0.0`), `||` separates alternatives, `^1.2.0` and `~1.2.0` work as in npm, `*` means any version, and a bare `1.2.3` means exactly that version.

### Areas

`area` decides in which menu area the module's section appears. It also places the module in the role editor and in the module list. It only groups: it does not switch the module on or off.

| `area` | Menu area in the left bar |
|---|---|
| `main`, `werkzeuge` | Start |
| `zeit`, `abwesenheit`, `partner`, `projekte`, `rechnungen`, `kontingente` | Organisation |
| `sicherheit` | ISMS |
| `dokumente` | Documents |
| `ai` | AI |
| `teams`, `system` | Administration |

Packages from before October 2026 may still name `verwaltung`. A released package does not change, so it stays valid and keeps its place under Organisation. A new package names one of the keys in the table.

A tenant can switch business areas off in the setup step <Ui>Areas</Ui> (see [First run](/setup/first-run/#step-3-areas)). A package is not affected: its menu section, pages, records, actions and AI Assistant tools stay for everyone with its permissions, and references to core data keep working even when the area that owns the data is off. Only deactivating or uninstalling the package takes it away. The one exception is an action of type `core-action`: it runs an action of nexxus itself, and nexxus leaves out the actions of switched-off areas.

## Keys nexxus derives

You write short keys in the manifest. nexxus turns them into full keys that appear in the role editor, in the AI Assistant's tools and in the API:

| What | Full key | Example |
|---|---|---|
| Read and write permission of an entity | `<module>.<entity>.read`, `<module>.<entity>.write` | `acme.besucher.besuch.read` |
| Extra permission | `<module>.<permission>` | `curiaos.kanzlei.freigeben` |
| Role | `<module>.<role>` | `acme.besucher.empfang` |
| AI Assistant tool of an entity (search, read) | `nx_<publisher>_<name>_<entity>_<search or get>` | `nx_acme_besucher_besuch_search` |
| AI Assistant action of an entity (create, change) | `<module>:<entity>.<create or update>` | `acme.besucher:besuch.create` |
| AI Assistant action of an action | `<module>:<action>` | `curiaos.kanzlei:frist-berechnen` |
| Event of an entity | `<entity>.created`, `.updated`, `.deleted` | `besuch.created` |

In tool names, `-` becomes `_`; action keys keep it. In the role editor the permissions of a package carry their German labels: entity permissions are built from the plural, „Besuche lesen“ and „Besuche bearbeiten“ (read and edit visits), extra permissions use their `label`.

## Entities

An entity is a business object. All records of all packages live in one record store in nexxus; a package never creates database tables.

| Field | Type | Required | Meaning |
|---|---|---|---|
| `key` | string | yes | Lower case, digits, `_` and `-`. `actions` and `p` are reserved. |
| `label` | text | yes | Singular, e.g. "Visit". |
| `labelPlural` | text | yes | Plural, e.g. "Visits". Used for the menu, lists and permission labels. |
| `icon` | string | no | Material Symbols name, used for the <Ui>Data</Ui> tab. |
| `titleField` | string | no | The field that names a record in headers, links and reference pickers. Without it a record is called `#<id>`. |
| `searchFields` | list | no | Fields of type `string`, `text`, `enum` or `ref` that the search box looks in. A `ref` field finds the record by what it points at: a record of the package by that record's own `searchFields`, core data by its name, for example a party by the contact's name. |
| `journal` | boolean | no | The entity records what happened, for example who did what on an order. People never add, change or delete an entry, administrators included. The package adds entries through its module service, also for a person who may only read the entity, or through a `create` action. Lists and the AI Assistant offer no new, edit or delete there, and a data import never writes into it. An `update` or `dictate` action, a `core-action` with `store` and a `mail` with `set` may not aim at it. An older nexxus refuses a package with `journal` on upload. |
| `fields` | list | yes | At least one field. |

### Field types

| `type` | Stored as | Options and notes |
|---|---|---|
| `string` | text, trimmed | `maxLength`, default 500. |
| `text` | text | Long text, shown as a text area. `maxLength` default 20 000. Cannot be indexed. |
| `number` | number | `min`, `max`, `format`: `integer`, `decimal` or `currency` (shown in euros, or in the currency that `currency` names). |
| `boolean` | `true` or `false` | Shown as a toggle. |
| `date` | `"YYYY-MM-DD"` | |
| `datetime` | ISO 8601 with time zone, stored in UTC | |
| `enum` | the key of a value | `values`: list of `{"key", "label"}`, at least one. `multiple: true` stores a list. |
| `ref` | the id of a record | `target` (see below). `multiple: true` stores a list. |
| `json` | any JSON | Not indexed and not shown in generated forms. |
| `geo` | `{"lat": -33.92, "lng": 18.42}`, degrees in WGS84, seven decimal places | A place: `lat` from -90 to 90, `lng` from -180 to 180. Record pages show it as a pin on a small map that the form edits by typing, by searching for an address or by moving the pin. `geocodeFrom` fills it from address fields. Cannot be indexed and never appears in a public form. |

A `ref` points to:

- an entity of the same package: `"target": "besuch"`, or
- a record of nexxus: `core:partner` (a firm or person in Contacts), `core:partnerContact` (a contact person of a partner), `core:user`, `core:dmsDocument` (a document in Files), `core:ismsMeasure`, `core:project`, `core:invoice` or `core:opportunity` (a possible deal under Sales).

A reference never points to another package's entity (see [Packages are self-contained](#packages-are-self-contained)).

When someone picks a value, nexxus checks that the record exists in the tenant and, for documents, that the person may read it. The picker for partners, contact persons, projects, invoices and ISMS measures is open to people who may see that list in nexxus; contact persons only offers active ones, and an invoice without a number shows as `#<id>`. Users and documents are open to everyone in the tenant, documents only those the person may read.

### Field options

| Option | Type | Meaning |
|---|---|---|
| `key` | string | Required. Lower case, digits, `_` and `-`. |
| `label`, `hint` | text | Field label and help text in forms. |
| `required` | boolean | Must have a value. |
| `default` | value | Set on create when the field is empty. Must fit the type. |
| `readonly` | boolean | Only actions, module services, migrations and data files may set it. A person's form can show it but not change it. |
| `readonlyWhen` | condition | Locks the field for people once the stored record meets a condition, in the shape of an action's `when`, for example `{ "field": "status", "in": ["zugeordnet", "verworfen"] }`. See below. |
| `indexed` | boolean | Makes the field filterable and sortable. Not for `text`, `json` and `geo`. |
| `unique` | boolean | No two records may share the value (compared trimmed and ignoring case). Only for `string` and `number`, implies `indexed`. |
| `sequence` | string | Only for `string`: a number format such as `"{yyyy}/{n:4}"`. nexxus assigns the next number on create. Implies `unique` and `readonly`. |
| `geocodeFrom` | list | Only for `geo`: fields of the same entity of type `string`, `text` or `enum`, for example `["address", "city", "country"]`. See below. |
| `currency` | string | Only for a `number` with `format: currency`: the ISO 4217 code in capitals, for example `"ZAR"`. Lists and record pages show the amount with the currency's symbol in the reader's language, for example `R 12,500.00` or `12.500,00 R`. Without it the amount is in euros (`€12,500.00`, `12.500,00 €`). An unknown code is refused on upload. |
| `group` | string | Section key in the generated form when the record view has no `sections`. |
| `maxLength`, `min`, `max`, `format` | | See the types above. |
| `values`, `multiple` | | For `enum` (and `multiple` also for `ref`). `unique` and `multiple` exclude each other. |
| `target` | string | For `ref`. |
| `accept` | list | MIME types for document references, for example `["image/*"]`. Checked for files a view uploads into the field (`nx.files.upload`), not when a person picks an existing document. Only image types make the record page show the documents as pictures (see below). |

A currency is a label, not a conversion: a new version that changes `currency` shows the stored amounts in the new currency. Mail texts and templates get the number without a symbol (`12.500,00`, in English `12,500.00`, in Polish `12 500,00`) and write the currency themselves.

With `geocodeFrom`, nexxus finds the coordinates itself. When a record is created or one of the named fields changes, and the geo value is empty or the address changed, nexxus joins the text of those fields (a choice list by its label), looks it up in the address search of the installation and stores the first hit. If the same request sets the geo value itself, that value wins. A lookup that finds nothing, fails or takes longer than three seconds never stops the save: the value stays as it was. Data and sample data files bring their coordinates themselves; installing a package does not look anything up.

In the form, <Ui>Search for an address</Ui> above the map asks the same address search from the third character on and offers up to five hits. Choosing one sets the pin and shows it at street level. The search runs on the server, so the browser asks no third party, and each person has 20 searches a minute.

A `ref` to `core:dmsDocument` whose `accept` lists only image types shows its documents as thumbnails on the record page. A click opens a picture large, and the arrow keys page through the others. While editing, the thumbnails stand under the picker. Without `accept`, nexxus does the same when every chosen file name ends in an image extension (`.jpg`, `.png`, `.webp` and the like).

`readonlyWhen` locks a field once a record has reached a certain state, for example the sender, date and subject of an incoming letter after it was assigned to a file:

```json
{ "key": "absender", "type": "string", "readonlyWhen": { "field": "status", "in": ["zugeordnet", "verworfen"] } }
```

The condition names fields of the same entity, like an action's `when` (also `set` and `all`), and every value in `in` must be a key of that choice list. nexxus reads it from the record as stored, not from the change being saved: a new record is never locked, and the save that sets the status may still carry the fields it locks. Once locked, the record page shows the field read-only with a note, and a change by a person is refused with a message that names the reason, for example <Ui>Absender ist gesperrt: Status ist „Zugeordnet“.</Ui> Saving the unchanged value passes. Actions, module services and migrations still write the field, and the AI Assistant knows about the lock. A dictation button (`type: dictate`) into a locked field is not offered, and in the calendar a record whose start or end field is locked cannot be dragged, resized or moved with the keyboard. `readonlyWhen` cannot stand beside `readonly`, on a `sequence` field or in `settings`. A package with `readonlyWhen` needs nexxus 0.14.0 or later and therefore declares `"platform": ">=0.14.0 <1.0.0"`.

Every `ref` is indexed automatically. A `sequence` knows the placeholders `{yyyy}`, `{yy}` and exactly one `{n}` or `{n:<digits>}`, for example `"GER-{n:4}"` gives `GER-0001`. With a year in the format, the counter starts again every year; it counts per tenant, module, entity and field.

## Views

Views decide what people see. There are five types:

| `type` | Rendered by | What it is |
|---|---|---|
| `list` | nexxus | The list of an entity: `entity`, `columns`, `filters`, `sort`, `create`. |
| `record` | nexxus | The <Ui>Data</Ui> tab of a record: `entity`, `sections`, `related`, `actions`. |
| `related` | nexxus | A tab with records that point to another record, for example on a partner. |
| `custom` | your HTML, in a sandbox | A page, record tab, record dialog or dashboard tile: `slot`, `html`, `target`, `when`. |
| `map` | nexxus | The records of an entity on a map: `entity`, `geoField`, `titleField`, `subtitleFields`, `colorField`, `imageField`. |

| Field | Used by | Meaning |
|---|---|---|
| `key` | all | Required. Lower case, digits, `_`, `.` and `-`, e.g. `besuch.list`. |
| `type` | all | Required. |
| `label`, `icon` | all | Tab, menu and dialog label. |
| `entity` | list, record, related, map | The entity shown. |
| `columns` | list, related | Field keys, or `{"field": "status", "display": "badge"}`. |
| `filters` | list | Indexed fields offered as filters. Choice lists become multi-selects, references a picker, dates a range. |
| `sort` | list | `{"field": "<indexed field>", "dir": "asc"}` or `"desc"`. Without it the newest record comes first. |
| `create` | list | `true` shows the button to create a record. |
| `sections` | record | `[{"key", "label", "fields": [...]}]`. Only the listed fields appear on the <Ui>Data</Ui> tab. |
| `related` | record | `[{"entity", "via", "label", "columns"}]`: lists of records whose reference field `via` points here. |
| `actions` | record | Exactly which actions and dialog views the record's action bar offers, in this order. |
| `slot`, `target` | related, custom | Where the view appears and on which entity. |
| `via` | related | The reference field that points to the target. |
| `html` | custom | `ui/<name>.html`. |
| `when` | custom | Show the view only when a field has certain values. |
| `size` | custom | `s`, `m` or `l` for dashboard tiles. |
| `geoField` | map | Required. The `geo` field that places a record. Records without a value are left out. |
| `titleField` | map | The marker's title. Without it, the entity's `titleField`. |
| `subtitleFields` | map | Fields shown under the title. |
| `colorField` | map | A `string`, `enum`, `boolean` or single `ref` field: each value gets its own colour. |
| `imageField` | map | A `ref` to `core:dmsDocument`: its first picture stands beside the record in the list next to the map. |

A map of holiday homes, coloured by region, with a photo per house in the list, as its own menu entry:

```json
{"key": "listing.map", "type": "map", "entity": "listing", "label": {"de": "Karte", "en": "Map"},
 "geoField": "location", "titleField": "name", "subtitleFields": ["city", "kind"], "colorField": "region", "imageField": "photos"}
```

nexxus draws the map itself, not in the sandbox of a custom view, and a click on a marker opens the record. The list beside the map shows the records in the visible part of the map: moving or zooming narrows it, and <Ui>Show all</Ui> brings every marker back into view. The map comes from the server of the installation; where none is set up, the view shows a placeholder.

To open the map on one record, a custom view calls `nx.nav.open({view: 'listing.map', id})`, which opens `/x/<module>/p/<view>?focus=<id>`. The map then shows that record at street level with its marker chosen and its row marked in the list. If the record has no place or does not exist, the map shows all records as it does without `focus`.

Without a `list` view, nexxus builds a list from the title field and the first four indexed fields. Without a `record` view, the <Ui>Data</Ui> tab shows every field except `json`, grouped by `group`. The slots, the record page layout and the SDK for custom views are described in [Views and the view SDK](/modules/views/).

## Menu

| Field | Type | Required | Meaning |
|---|---|---|---|
| `key` | string | yes | |
| `label` | text | yes | The entry's label. |
| `icon` | string | no | Material Symbols name. |
| `view` | string | yes | A `list` view, a `map` view or a `custom` view in the slot `page`. |
| `order` | number | no | Position within the module's section. |

The entries appear in a section named after the module, after the built-in sections of the menu area. A person sees an entry only if they may read its entity; for a page it is enough to hold any permission of the module.

## Actions

Actions are the workflow steps of a module: lend a device, approve a draft, complete a deadline.

```json
{
  "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
}
```

| Field | Type | Required | Meaning |
|---|---|---|---|
| `key` | string | yes | |
| `label` | text | yes | Button label. |
| `type` | string | yes | `update`, `create`, `core-action`, `service` or `mail`. |
| `entity` | string | yes | The entity the action runs on. nexxus refuses an action without an existing entity. |
| `icon` | string | no | Material Symbols name. |
| `set` | object | for `update` | Field values to set on the record. |
| `creates`, `values` | string, object | for `create` | The entity to create and its field values. |
| `coreAction`, `scope`, `input`, `store` | | for `core-action` | Runs an action of nexxus itself: `partner.create`, `partnerContact.create`, `opportunity.create`, `ismsMeasure.create` or `invoice.createDraft`. `scope` must be listed under `scopes`. `input` is passed on exactly, `store: {"field", "from": "id"}` writes the result into a field. |
| `service`, `operation`, `async` | | for `service` | Calls an operation of a module service. With `async: true` nexxus answers right after the service accepted the call. |
| `mail`, `log`, `onSent`, `onFailed` | | for `mail` | Sends the record by e-mail, see [Mail actions](#mail-actions). |
| `permission` | string | no | An extra permission of the module. Without it the action needs the entity's write permission. |
| `when` | object | no | The action is offered and allowed only when the condition holds. |
| `confirm` | text | no | nexxus asks this question before running the action. |
| `aiTool` | boolean | no | The AI Assistant may propose this action in the chat. |
| `distinct` | list | no | At least two fields (or `$user`) whose values must differ, e.g. `["geprueft_von", "$user"]`: the checker may not be the person running the action. |

Values in `set`, `values` and `input` may use templates. A value that is exactly one template keeps its type, so `"{{record.id}}"` stays a number.

| Template | Becomes |
|---|---|
| `{{record.<field>}}` | A field of the record the action runs on. |
| `{{record.<reference>.<field>}}` | A field of a record of this package that the reference points to (one level). |
| `{{record.id}}` | The record's id. |
| `{{payload.<key>}}` | A value the caller sent. If the key is missing, an `update` leaves the field unchanged and a `create` leaves it empty; an explicit `null` empties it. |
| `{{user.id}}` | The person running the action. |
| `$today`, `$now` | Today's date, the current time. |

`when` takes `{"field": "<key>", "in": [values]}` or `{"field": "<key>", "set": true}` (or `false`). Several conditions that must all hold stand in a list under `all`: `{"all": [{"field": "status", "in": ["freigegeben"]}, {"field": "kanal", "in": ["email"]}]}`. Inside `all` there are only single conditions, no second `all`. The same forms work wherever a manifest takes `when`, on views too. nexxus hides an action whose condition does not hold and checks it again on the server before the action runs.

Actions that need input (a person, a date) do not belong in the record's action bar. Write a dialog view for them that calls the action with its input. The [first module](/modules/first-module/) shows that pattern.

### Mail actions

An action of type `mail` sends one record by e-mail to a contact, optionally with a letter as PDF: an approved draft goes to the insurer, for example. A person always sends it, after looking at a preview. nexxus finds the address itself, files the PDF in Files, records the dispatch in an entity of the package and later notes whether the mail went out.

```json
{
  "key": "entwurf-versenden", "entity": "entwurf", "type": "mail",
  "label": {"de": "Versenden", "en": "Send"}, "icon": "send",
  "permission": "versenden", "scope": "core:mail:send",
  "when": {"all": [{"field": "status", "in": ["freigegeben", "versand_fehlgeschlagen"]},
                   {"field": "kanal", "in": ["email"]}]},
  "mail": {
    "to": "empfaenger",
    "subject": "{{record.betreff}}",
    "textSetting": "versand_mailtext",
    "replyToSetting": "versand_antwort_an",
    "locale": "de",
    "pdf": {"template": "templates/brief.html",
            "fileName": "{{record.akte.aktennummer}} {{record.betreff}}.pdf",
            "folder": ["Kanzlei", "Schriftverkehr", "{{record.akte.aktennummer}}"]}
  },
  "log": {"entity": "versand",
          "values": {"akte": "{{record.akte}}", "entwurf": "{{record.id}}", "betreff": "{{record.betreff}}"}},
  "set": {"status": "im_versand"},
  "onSent": {"set": {"status": "versendet", "versendet_am": "$now"}},
  "onFailed": {"set": {"status": "versand_fehlgeschlagen"}}
}
```

| Field | Required | Meaning |
|---|---|---|
| `scope` | yes | Always `core:mail:send`, and the scope stands under `scopes`. |
| `mail.to` | yes | A reference field of the action's entity with target `core:partner`, not `multiple`. The mail goes to that contact's e-mail address. |
| `mail.subject` | yes | The subject, with placeholders. One line. |
| `mail.textSetting` | yes | A setting of type `text` or `string` that holds the mail text, plain text with placeholders. Administrators can change it on the package page. nexxus sends it in its own mail layout as a letter of the organisation, with its name or logo; blank lines separate the paragraphs. |
| `mail.replyToSetting` | no | A setting of type `string` with the address replies go to. |
| `mail.locale` | no | `de` (the default), `en` or `pl`: the language the values in subject, mail text, file name and folders read in. |
| `mail.pdf.template` | for a PDF | A template of the package, `templates/<name>.html` or just `<name>`. |
| `mail.pdf.fileName` | for a PDF | The PDF's name, with placeholders. nexxus replaces the characters file names may not contain, such as `/`, `:` and `?`, with `-`, keeps at most 200 characters and ends the name in `.pdf`. |
| `mail.pdf.folder` | for a PDF | The folder path in Files, one name per entry, with placeholders. A `/` inside a value becomes `-`, so `2026/0184` names the folder `2026-0184`. |
| `log.entity` | yes | The entity of the package that records each dispatch. |
| `log.values` | no | Further values for the dispatch record, with the templates of `create`. |
| `set` | no | Set on the record when the mail is queued. |
| `onSent.set`, `onFailed.set` | no | Set on the record when the mail went out or finally failed. `$now` and record templates work. |

**The address comes from the contact.** nexxus reads it from the contact in `mail.to`, never from the call or a template. The person who sends needs the right to read contacts (`partner.partner.read`, administrators have it), because the preview shows the address. A contact without an e-mail address stops the action with „Der Empfänger hat keine E-Mail-Adresse. Bitte im Kontakt ergänzen.“ (the recipient has no e-mail address, please add one to the contact).

**Placeholders.** Subject, mail text, file name, folder names and the template know these:

| Placeholder | Becomes |
|---|---|
| `{{record.<field>}}`, `{{record.id}}` | A field of the record, as its page shows it: references by name, choices by their label, dates as `14.10.2026`. |
| `{{record.<reference>.<field>}}` | A field of the record of this package the reference points to (one level). |
| `{{empfaenger.name}}` | The contact's name. |
| `{{empfaenger.anschrift}}` | The contact's postal address, one line per line of the address. |
| `{{settings.<key>}}` | A setting of the installation. |
| `{{heute}}` | Today's date, `dd.MM.yyyy`. |

**Language of the values.** Without `mail.locale` values read German: `14.10.2026`, `1.252,50`, the German label, "Ja". With `"locale": "en"` they read English, as British statement lines do: `14/10/2026`, `1,252.50`, the English label, "Yes". With `"locale": "pl"` they read Polish: `14.10.2026`, `1 252,50` (a no-break space between the thousands), the Polish label, "Tak". A label missing in that language is taken in English, else in German. The template ignores `mail.locale` and follows its own `<html lang>`: `lang="en"` (or `en-GB`, `en-ZA`) reads English, `lang="pl"` (or `pl-PL`) Polish, anything else German. Amounts never get a symbol; write it in the text or template (`R {{record.payout}}`). Times are in Europe/Berlin.

`{{payload.<key>}}` does not exist here: what goes out is exactly what the person saw in the preview. In the template every value is escaped, and line breaks become `<br>`. A placeholder that names nothing stays in the text as written, so a typo in the mail text shows in the preview.

**Preview and confirmation.** Before sending, the view asks nexxus for a preview: recipient name and address, subject, mail text and the PDF's file name. It writes nothing and comes with a token that is valid for ten minutes. The send carries that token; nexxus composes the mail again and sends only if it is exactly what the token was made for: the same person, the same record in the same version, the same address, subject, text and file name. A token sends once. The calls are described in [Views and the view SDK](/modules/views/).

**What happens on send**, in one step that either completes or leaves nothing behind:

<Flow
  steps={[
    { icon: 'file-text', title: 'PDF', text: 'nexxus fills the template and has it rendered as PDF, without JavaScript.' },
    { icon: 'folder-open', title: 'Files', text: 'The PDF lands in the folder path, where the person may edit.', tone: 'lavender' },
    { icon: 'mail', title: 'Queue', text: 'The mail is queued with the PDF attached and the reply-to address.' },
    { icon: 'list-checks', title: 'Record', text: 'A dispatch record with status in_warteschlange, then set on the record.', tone: 'cyan' },
  ]}
  caption="The mail queue sends within a minute and reports the result back."
/>

The folder path starts at a top folder of that name that the person can see, their own or one shared with them. A folder "Kanzlei" that an administrator creates and shares with the team is therefore used by everyone who sends, and its rights decide who sees the letters. If nobody shared such a folder with the person, the path starts in their own files. nexxus creates missing folders only where the person may edit and needs the right to edit the folder the PDF lands in.

The mail queue sends within a minute. Once the mail went out, nexxus sets the dispatch record to `versendet` with `message_id` and `versendet_am` and applies `onSent`; once it finally failed, to `fehlgeschlagen` with `fehler` and applies `onFailed`. "Sent" means the mail server accepted the mail, not that it arrived. Sending again after a failure writes a new dispatch record; the old one stays. The mails also appear under <MenuPath to="Operations > Maintenance > Email delivery" />. Forwarding such a mail from there needs the right to open its PDF in Files.

**The dispatch entity** needs these fields, which nexxus fills itself; `log.values` may not name them:

| Field | Type | Filled |
|---|---|---|
| `adresse` | `string` | the address the mail went to |
| `dokument` | `ref` to `core:dmsDocument` | the PDF, when the action has one |
| `status` | `enum` with at least `in_warteschlange`, `versendet`, `fehlgeschlagen` | as described above |
| `message_id` | `string` | after sending |
| `fehler` | `text` | after a final failure |
| `ausgeloest_von` | `ref` to `core:user` | the person who sent |
| `ausgeloest_am`, `versendet_am` | `datetime` | when the person sent, when the mail went out |

The dispatch records are a log of what left the firm, so nobody changes them afterwards: every field in the table and in `log.values` must be `readonly`, no other `update` action of the package may aim at the dispatch entity, and nexxus refuses to create or delete a dispatch record through the record API (409 `MAIL_LOG`). Another `create` action may add an entry the mail queue cannot know of, such as a letter someone posted. It does not set `message_id` or `fehler`: only the queue reports those. The pages offer no button for it, and the AI Assistant can read the dispatch records but not write them. They go only when the package is uninstalled with its data.

**The PDF service.** The PDF is rendered by the hosted PDF service, so the letter leaves the server for a moment. The operator of an instance decides whether that is allowed (setting `nexxus.modules.mail.allow-hosted-pdf-renderer`); without it, preview and send of an action with `pdf` stop with a message that names the setting.

A mail action is never a tool of the AI Assistant: nexxus refuses `"aiTool": true` and an entry under `aiTools.actions` at upload. The AI Assistant can write the draft, a person sends it.

## Permissions and roles

```json
"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"]}
]
```

- Every entity has a read and a write permission without being declared. `permissions` adds more (`key` and `label` are required). A permission may not have the same key as an entity.
- A role lists short keys: `<entity>.read`, `<entity>.write` or an extra permission.
- At install nexxus creates one access role per manifest role and lets the administrator assign people to each one in the install dialog. Nobody else gets a role automatically. Later assignments happen in the role editor, see [Roles](/setup/roles/).

## Scopes

Scopes name the data outside the package that the module's **actions and module services** may use. Custom views do not need them: they always act with the rights of the person looking.

| Scope | Allows |
|---|---|
| `core:partner:read` | Read partners |
| `core:partner:create` | Create contacts, through a `core-action` `partner.create` |
| `core:user:read` | Read users |
| `core:dmsDocument:read` | Read documents in Files |
| `core:dmsDocument:create` | Store files a view uploads (`nx.files.upload`, for example photos from a tablet) as documents in Files, see [Views](/modules/views/#photos-and-files-from-the-view) |
| `core:ismsMeasure:read`, `core:ismsMeasure:create` | Read and create ISMS measures |
| `core:partnerContact:read` | Read contact persons |
| `core:partnerContact:create` | Create a contact person of a contact, through a `core-action` `partnerContact.create`; nexxus sets <Ui>Active</Ui> and <Ui>ISMS recipient</Ui> itself when they are left out |
| `core:project:read` | Read projects |
| `core:invoice:read` | Read invoices |
| `core:invoice:create` | Create draft invoices with one position, through a `core-action` `invoice.createDraft`; a person issues them |
| `core:opportunity:read` | Read opportunities |
| `core:opportunity:create` | Create opportunities, through a `core-action` `opportunity.create`; without one it gets the first open stage and EUR. Needs the Sales area |
| `core:mail:send` | Send e-mails on behalf of your organisation, through a [mail action](#mail-actions) |

The administrator agrees to every scope in the install dialog. An update that adds scopes asks again. Scopes on another package's records (`pkg:<package>:<entity>:read` or `:write`) do not exist any more; nexxus refuses them at upload.

## Services

```json
"services": [
  {"key": "rechner", "label": {"de": "Rechner", "en": "Calculator"},
   "url": "http://localhost:8390/rechner",
   "events": ["besuch.created"], "operations": ["pruefen"]}
]
```

| Field | Type | Required | Meaning |
|---|---|---|---|
| `key` | string | yes | |
| `label` | text | yes | Shown to the administrator and in error messages. |
| `description` | text | no | |
| `url` | string | yes | `http://` or `https://`. The operator of the instance can point it elsewhere. |
| `events` | list | no | Changes the service receives: `<entity>.created`, `.updated`, `.deleted` of the package's own entities. |
| `operations` | list | no | Operations that `service` actions may call. |

How services are called, authenticated and deployed is described in [Module services](/modules/services/).

## Settings

`settings` uses the same shape as entity fields, without `ref`. Administrators edit the values on the package page; module services receive them with every call.

```json
"settings": [
  {"key": "vorfrist_werktage", "type": "number", "format": "integer", "min": 1, "max": 10, "default": 3, "required": true,
   "label": {"de": "Vorfrist (Werktage vor Fristende)", "en": "Early warning (working days before the deadline)"}}
]
```

## AI Assistant: aiTools and assistant

```json
"aiTools": {"entities": ["geraet"], "actions": ["frist-berechnen"]},
"assistant": {
  "instructions": {"de": "Du hilfst bei den Leihgeräten des Teams. ...", "en": "You help with the team's loan equipment. ..."},
  "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."}}
  ],
  "avatars": [
    {"key": "greta", "name": "Greta", "role": {"de": "Geräteausgabe", "en": "Equipment desk"}, "gradient": "teal",
     "persona": {"de": "Nennt immer das Rückgabedatum. ...", "en": "Always names the due date. ..."},
     "greeting": {"de": "Welches Gerät suchen Sie?", "en": "Which device are you looking for?"},
     "expertise": {"de": ["Ausleihe", "Rückgabe"], "en": ["Loans", "Returns"]}}
  ],
  "skills": [
    {"key": "rueckgabe-erinnern", "avatar": "greta",
     "name": {"de": "An Rückgabe erinnern", "en": "Remind about return"},
     "description": {"de": "Entwirft eine Erinnerung an die Person, die das Gerät hat.", "en": "Drafts a reminder to the person who has the device."},
     "instructions": {"de": "Lies das Gerät und die Ausleihe. Entwirf die Erinnerung, versende nichts. ...", "en": "Read the device and the loan. Draft the reminder, send nothing. ..."},
     "buttons": [
       {"entity": "geraet", "label": {"de": "An Rückgabe erinnern", "en": "Remind about return"},
        "prompt": {"de": "Entwirf eine Erinnerung zur Rückgabe von {{title}} (fällig am {{record.rueckgabe}}).", "en": "Draft a reminder to return {{title}} (due {{record.rueckgabe}})."}}
     ]}
  ]
}
```

| Field | Meaning |
|---|---|
| `aiTools.entities` | Per listed entity, the AI Assistant gets the tools to search and read for people who may read it, and the actions to create and change for people who may write it. |
| `aiTools.actions` | The AI Assistant may propose each listed action. `"aiTool": true` on the action does the same. Never for a mail action. |
| `assistant.instructions` | Installed as a skill of the tenant, always on, switched off while the module is deactivated and removed when it is uninstalled. |
| `assistant.prompts` | Example prompts shown as chips in the assistant column when the named view is on screen. `label` and `prompt` are required, with a German prompt at least. |
| `assistant.avatars` | Up to 5 avatars, installed as company-wide avatars of the tenant. `key` (kebab-case) and `name` are required; `role`, `gradient`, `persona` (at most 1200 characters), `greeting` and `expertise` (tags without commas) are optional. |
| `assistant.skills` | Up to 20 skills, installed as company-wide skills that free chat finds by their `description`. `key`, `name`, `description` and `instructions` are required; `avatar` names one of the package's avatars, who then answers. |
| `assistant.skills[].buttons` | Up to 3 AI buttons per skill on the record pages of `entity`: an entity of this package, or a core page as `core:partner`, `core:dmsDocument`, `core:invoice` or `core:project`. A click opens a dialog with the skill, its avatar and the `prompt` filled in from the record; the person can change it before sending. Placeholders: `{{title}}` and `{{record.<field>}}`, where the field exists on the package entity (on a core page any field name passes). `label` at most 80 characters, `prompt` at most 2000. |

Texts are installed in the tenant's default language (<MenuPath to="Administration > Operations > System configuration" />), else English, else German. A button is shown to people who may read the entity (on a core page: who may open that page) and see the skill, while the module is active. Upgrades rewrite avatars, skills and buttons by key; what a new version drops is removed, and its avatars are archived so their conversations stay. The buttons of a package skill cannot be changed in the skill editor.

Every tool and every action runs with the rights of the person chatting. The AI Assistant never writes on its own: it proposes the actions as a card in the chat, the server checks input, state and permission of each step, and only the person's click runs them, several at once in one transaction. An approval that only a person may give, such as releasing a letter, should be an action **without** `aiTool`. More in [Working with the AI Assistant](/use/assistant/).

## Start page templates: dashboards

A template replaces the whole start page layout in one click (see [Start from a template](/use/dashboard/#start-from-a-template)). A package can bring up to three templates of its own, usually one per role it creates. They are made of the package's tiles (views with `slot` `dashboard.widget`) and built-in tiles.

```json
"dashboards": [
  {"key": "ausgabe", "label": {"de": "Geräteausgabe", "en": "Equipment desk"},
   "description": {"en": "What comes back today and what is on loan."}, "icon": "inventory_2",
   "roles": ["verwaltung"],
   "tiles": [
     {"view": "heute-faellig"},
     {"view": "verliehen", "width": 2},
     {"widget": "calendar", "width": 2},
     {"widget": "ask"}
   ]}
]
```

| Field | Meaning |
|---|---|
| `key` | Unique in the list. |
| `label` | The template's name, at most 60 characters per language. |
| `description` | Optional, at most 300 characters. |
| `icon` | Optional, a Material Symbols name. Without one, the package's icon. |
| `roles` | Optional, keys of the package's `roles` whose holders start on this template. A role names at most one template. |
| `tiles` | 1 to 12 tiles in the order of the page. Each names exactly one of `view` and `widget`, no tile twice. The greeting is always first and is not listed. |
| `tiles[].view` | A view of this package with `slot` `dashboard.widget`. |
| `tiles[].widget` | A built-in tile without settings: `ask` (<Ui>Ask the assistant</Ui>), `onboarding` (<Ui>Setup</Ui>), `ai-sessions` (<Ui>AI Gateway</Ui>), `users` (<Ui>Users</Ui>) or `calendar` (<Ui>Calendar</Ui>). Report tiles and embedded views are not allowed, because their settings point at the tenant's own data. |
| `tiles[].width` | Optional, the width in columns. A view of size `s` 1 to 2, of size `m` or `l` 2 to 4; `ask` and `onboarding` 2 to 4, `ai-sessions` and `users` 1 to 2, `calendar` 1 to 4. Without it a view starts at 1 (`s`), 2 (`m`) or 4 (`l`) columns, a built-in tile at its usual width. |

The templates come before <Ui>Standard</Ui>, <Ui>KI</Ui> and <Ui>Leer</Ui>, with the module's name beside the tile count, for example <Ui>4 widgets · Module Loan equipment</Ui>. A person sees them while the module is active and they may use it, the same check as for the module's menu, in the tenant's default language. When applied, a built-in tile the person may not see is left out, such as the <Ui>Calendar</Ui> without the right for it. nexxus reads the templates from the manifest on every call: an upgrade changes them at once, deactivating or removing the module takes them away. A layout already applied keeps its tiles. A template is not a default for everyone; for that, an admin applies it and saves the layout as the default.

With `roles`, a template is also the start page of those roles. Whoever holds one of them and has not customised their dashboard sees the template right after signing in, before the tenant's default and <Ui>Standard</Ui>, and the card reads <Ui>Start page for Equipment management</Ui>. It stays live: an upgrade changes it for these people too, until they edit their dashboard or apply a template. Holding roles of two packages, the package whose key sorts first wins. In such a start page a package tile shows only when its module is active and the person may use it, administrators included; nexxus checks the module, not what the view reads, so put only tiles in a role's template that the role can read in full.

A package with `dashboards` needs nexxus 0.14.0 or later and therefore declares `"platform": ">=0.14.0 <1.0.0"`. An older version refuses it anyway, because it does not know the field.

## Migrations

```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"}]
  }
}
```

Each key is a version up to the package's own version. The steps `setDefault`, `renameValue`, `renameField`, `copyField`, `deleteField` and `deleteEntity` (the last two only in a new major version) and the order they run in are described in [Data and upgrades](/modules/data-and-upgrades/).

## Packages are self-contained

A package works with its own entities, with core data (the `core:` targets [above](#field-types)) and with nothing else. It never builds on another package: no reference, view, scope, event or data row names another package's entities, and `requires` names built-in modules only. What belongs together ships as one package. Until 9 October 2026 packages could build on each other. That tied installations together (one had to be installed first, and could not be deactivated while the other used it) and spread one business area over two installations.

nexxus refuses an upload that names another package. Each finding is one line that starts with the place and „Pakete bauen nicht aufeinander auf:“ (packages do not build on each other):

| Where | Refused | Message after the place |
|---|---|---|
| `requires` | a package key | „requires nennt das Paket 'muster.akten'. Was zusammengehört, gehört in ein Paket; requires nennt nur Kernmodule“ |
| `scopes` | `pkg:<package>:<entity>:read` or `:write` | „scope 'pkg:muster.akten:akte:read' greift auf Datensätze des Pakets 'muster.akten' zu“ |
| `target` of a reference or of a view | `<package>:<entity>` | „target 'muster.akten:akte' ist ein Datenobjekt des Pakets 'muster.akten'“ |
| `services[].events` | `<package>:<entity>.<op>` | „Ereignis 'muster.akten:aktendokument.created' kommt aus dem Paket 'muster.akten'“ |
| `data/`, `demo/` | `{"$key": "<package>:<entity>:<key>"}` | „$key 'muster.akten:akte:2026-0201' nennt einen Datensatz des Pakets 'muster.akten'“ |

Write an entity by its key alone. The package's own key in front (`acme.besucher:besuch` inside `acme.besucher`) names no other package, but it is not how a manifest names its entities either: a target written that way is refused with „target 'acme.besucher:besuch' ist kein Datenobjekt des Pakets“ (is no entity of the package).

A version uploaded before the rule may still name another package. nexxus refuses to install it or to update to it: „Diese Version baut auf dem Paket 'muster.akten' auf. Pakete bauen nicht mehr aufeinander auf; was zusammengehört, kommt als ein Paket.“ An installation already running such a version keeps running, but reaches nothing of the other package.

A package does not depend on the tenant's business areas either, see [Areas](#areas). The [CuriaOS case study](/modules/curiaos/) shows a package that grew out of two.

## How nexxus checks a package

<Flow
  steps={[
    { icon: 'file-archive', title: 'Archive', text: 'ZIP readable, at most 2 000 files and 64 MB unpacked, safe paths.' },
    { icon: 'file-code', title: 'Schema', text: 'module.json against the package schema: types, required fields, no unknown fields.', tone: 'lavender' },
    { icon: 'scale', title: 'Rules', text: 'References between parts, keys, ranges, files, changelog heading.' },
    { icon: 'badge-check', title: 'Hashes and signature', text: 'MANIFEST.sha256 matches, the signature is valid or the package counts as unsigned.', tone: 'cyan' },
  ]}
  caption="nexxus collects every problem of a step and reports them together, one per line."
/>

The rules go beyond the schema. A few of them, with the message nexxus shows (in German, as the server writes it):

| Rule | Message |
|---|---|
| `publisher` matches the key | „publisher 'acme' muss dem Teil vor dem Punkt in 'firma.besucher' entsprechen“ |
| `area` is a known area | „area 'lager' ist kein Menübereich (...)“ |
| Filters and sorting only on indexed fields | „Filter 'name' ist nicht indexed“ |
| Menu entries point to lists or pages | „nur Listen und Seiten (slot page) gehören ins Menü“ |
| Roles name existing permissions | „Rolle 'empfang' nennt das unbekannte Recht 'besuch.delete'“ |
| Reserved entity keys | „Datenobjekt 'p' ist ein reservierter Name“ |
| Packages do not build on each other | „Pakete bauen nicht aufeinander auf: scope 'pkg:muster.akten:akte:read' greift auf Datensätze des Pakets 'muster.akten' zu“ |
| Changelog heading for the version | „CHANGELOG.md: keine Überschrift '## [1.0.0]' für die Version aus module.json“ |
| View files exist and are small enough | „ui/empfang.html fehlt“, „ui/empfang.html ist größer als 2 MB“ |
| A mail action needs its scope | „scope 'core:mail:send' muss unter scopes stehen“ |
| A mail action is never an AI tool | „eine Mail-Aktion ist nie ein KI-Werkzeug (aiTool): ein Mensch löst jeden Versand aus“ |
| Placeholders of a mail exist | „Platzhalter '`{{payload.betreff}}`' gibt es in Mails nicht: was hinausgeht, steht vorher unverändert in der Vorschau“ |

<Screenshot src={shot_nxm_04} url="/modules/pakete" alt={"A refused upload with the list of problems"} caption={"nexxus lists every problem of a refused package at once."} />

Each message starts with the place it refers to, such as `module.json views.besuch.list:`. On upload, the catalogue shows <Ui>"&lt;file&gt;" was not accepted.</Ui>, the sentence „Das Paket ist fehlerhaft:“ (the package is faulty) and every problem as a list. Before you upload, the packaging script's `verify` command checks hashes, file list and changelog heading; the manifest rules are only checked by nexxus.

## Next steps

<CardGrid>
  <LinkCard title="Build your first module" description="Write a complete package with a dialog, a page and sample data, and install it." href="/modules/first-module/" />
  <LinkCard title="Views and the view SDK" description="Slots, the record page layout and every function a custom view can call." href="/modules/views/" />
  <LinkCard title="Data and upgrades" description="Data files, versions, migration steps and what an update does to stored records." href="/modules/data-and-upgrades/" />
</CardGrid>