Skip to content

Modules (NXM)

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.

For
Developers
Time
25 min

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.

  • Directoryacme.besucher/1.0.0/
    • module.json the manifest (required)
    • CHANGELOG.md one section per version (required)
    • Directoryui/
      • empfang.html one file per custom view
    • Directorytemplates/
      • brief.html a letter that a mail action fills and sends as PDF
    • Directorydata/
      • besuch.json records every installation gets
    • Directorydemo/
      • besuch.json sample records, only on request
      • Directoryfiles/
        • 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
FileRequiredWhat it holds
module.jsonyesThe manifest, described below.
CHANGELOG.mdyesKeep-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>.htmlfor custom viewsOne self-contained HTML file per custom view, with inline CSS and JavaScript, at most 2 MB. See Views and the view SDK.
templates/<name>.htmlfor a mail action with PDFA static HTML template that nexxus fills and renders as PDF, at most 512 KB. No scripts, and it loads nothing: see Templates.
data/<entity>.jsonnoRecords every installation gets, for example a catalogue of requirements.
demo/<entity>.jsonnoSample records, created only when the administrator asks for sample data at the first install.
demo/files/<name>noFiles the sample records refer to, imported into Files.
MANIFEST.sha256noOne line <sha256> <path> per file, sorted by path. If present, it must list every other file with the right hash and nothing else.
SIGNATUREno{"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. Signing and uploading are covered in Package, install and update.

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.

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. 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.

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

{
"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 Data 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.

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.

FieldTypeRequiredMeaningExample
keystringyesThe module’s name, <publisher>.<name>, lower case, digits and - allowed. Everything else is derived from it."acme.besucher"
kindstringyesAlways "package"."package"
publisherstringyesMust equal the part of key before the dot."acme"
nametext, de and enyesShown in the menu, page titles and the catalogue.{"de": "Besucherbuch", "en": "Visitor log"}
descriptiontext, de and enyesShown in the catalogue.
iconstringnoA Material Symbols icon name."badge"
areastringyesThe business area the module belongs to (table below). It places the module, it does not switch it."projekte"
versionstringyesmajor.minor.patch, without a suffix."1.0.0"
platformstringnoThe nexxus versions the package runs on, as a version range.">=0.2.0 <1.0.0"
requiresobjectnoBuilt-in modules this one needs, each with a version range. Never another package.{"partners": ">=1.0.0"}
permissionslistnoExtra permissions besides read and write per entity.see Permissions and roles
roleslistnoRoles nexxus creates at install.
scopeslist of stringsnoData outside the package that actions and services may use. Needs the administrator’s consent.["core:partner:read"]
entitieslistnoThe business objects.see Entities
viewslistnoLists, record pages and custom views.see Views
menulistnoMenu entries.
actionslistnoWorkflow steps on records.
serviceslistnoModule services outside nexxus.
settingslistnoSettings of an installation, edited by administrators.
aiToolsobjectnoWhich entities and actions the AI Assistant gets tools for.
assistantobjectnoInstructions, example prompts, avatars, skills and AI buttons for the AI Assistant.
migrationsobjectnoSteps that change stored records during an update.
portalobjectnoThe public side of the package for customers without an account.see Customer portal
calendarlistnoRecords with a date that the nexxus calendar shows.see Calendar sources
dashboardslistnoUp to three start page templates, from nexxus 0.14.0.see Start page templates

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.

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.

areaMenu area in the left bar
main, werkzeugeStart
zeit, abwesenheit, partner, projekte, rechnungen, kontingenteOrganisation
sicherheitISMS
dokumenteDocuments
aiAI
teams, systemAdministration

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 Areas (see First run). 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.

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:

WhatFull keyExample
Read and write permission of an entity<module>.<entity>.read, <module>.<entity>.writeacme.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, .deletedbesuch.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.

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

FieldTypeRequiredMeaning
keystringyesLower case, digits, _ and -. actions and p are reserved.
labeltextyesSingular, e.g. “Visit”.
labelPluraltextyesPlural, e.g. “Visits”. Used for the menu, lists and permission labels.
iconstringnoMaterial Symbols name, used for the Data tab.
titleFieldstringnoThe field that names a record in headers, links and reference pickers. Without it a record is called #<id>.
searchFieldslistnoFields 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.
journalbooleannoThe 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.
fieldslistyesAt least one field.
typeStored asOptions and notes
stringtext, trimmedmaxLength, default 500.
texttextLong text, shown as a text area. maxLength default 20 000. Cannot be indexed.
numbernumbermin, max, format: integer, decimal or currency (shown in euros, or in the currency that currency names).
booleantrue or falseShown as a toggle.
date"YYYY-MM-DD"
datetimeISO 8601 with time zone, stored in UTC
enumthe key of a valuevalues: list of {"key", "label"}, at least one. multiple: true stores a list.
refthe id of a recordtarget (see below). multiple: true stores a list.
jsonany JSONNot indexed and not shown in generated forms.
geo{"lat": -33.92, "lng": 18.42}, degrees in WGS84, seven decimal placesA 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).

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.

OptionTypeMeaning
keystringRequired. Lower case, digits, _ and -.
label, hinttextField label and help text in forms.
requiredbooleanMust have a value.
defaultvalueSet on create when the field is empty. Must fit the type.
readonlybooleanOnly actions, module services, migrations and data files may set it. A person’s form can show it but not change it.
readonlyWhenconditionLocks 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.
indexedbooleanMakes the field filterable and sortable. Not for text, json and geo.
uniquebooleanNo two records may share the value (compared trimmed and ignoring case). Only for string and number, implies indexed.
sequencestringOnly for string: a number format such as "{yyyy}/{n:4}". nexxus assigns the next number on create. Implies unique and readonly.
geocodeFromlistOnly for geo: fields of the same entity of type string, text or enum, for example ["address", "city", "country"]. See below.
currencystringOnly 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.
groupstringSection key in the generated form when the record view has no sections.
maxLength, min, max, formatSee the types above.
values, multipleFor enum (and multiple also for ref). unique and multiple exclude each other.
targetstringFor ref.
acceptlistMIME 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, Search for an address 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:

{ "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 Absender ist gesperrt: Status ist „Zugeordnet“. 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 decide what people see. There are five types:

typeRendered byWhat it is
listnexxusThe list of an entity: entity, columns, filters, sort, create.
recordnexxusThe Data tab of a record: entity, sections, related, actions.
relatednexxusA tab with records that point to another record, for example on a partner.
customyour HTML, in a sandboxA page, record tab, record dialog or dashboard tile: slot, html, target, when.
mapnexxusThe records of an entity on a map: entity, geoField, titleField, subtitleFields, colorField, imageField.
FieldUsed byMeaning
keyallRequired. Lower case, digits, _, . and -, e.g. besuch.list.
typeallRequired.
label, iconallTab, menu and dialog label.
entitylist, record, related, mapThe entity shown.
columnslist, relatedField keys, or {"field": "status", "display": "badge"}.
filterslistIndexed fields offered as filters. Choice lists become multi-selects, references a picker, dates a range.
sortlist{"field": "<indexed field>", "dir": "asc"} or "desc". Without it the newest record comes first.
createlisttrue shows the button to create a record.
sectionsrecord[{"key", "label", "fields": [...]}]. Only the listed fields appear on the Data tab.
relatedrecord[{"entity", "via", "label", "columns"}]: lists of records whose reference field via points here.
actionsrecordExactly which actions and dialog views the record’s action bar offers, in this order.
slot, targetrelated, customWhere the view appears and on which entity.
viarelatedThe reference field that points to the target.
htmlcustomui/<name>.html.
whencustomShow the view only when a field has certain values.
sizecustoms, m or l for dashboard tiles.
geoFieldmapRequired. The geo field that places a record. Records without a value are left out.
titleFieldmapThe marker’s title. Without it, the entity’s titleField.
subtitleFieldsmapFields shown under the title.
colorFieldmapA string, enum, boolean or single ref field: each value gets its own colour.
imageFieldmapA 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:

{"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 Show all 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 Data 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.

FieldTypeRequiredMeaning
keystringyes
labeltextyesThe entry’s label.
iconstringnoMaterial Symbols name.
viewstringyesA list view, a map view or a custom view in the slot page.
ordernumbernoPosition 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 are the workflow steps of a module: lend a device, approve a draft, complete a deadline.

{
"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
}
FieldTypeRequiredMeaning
keystringyes
labeltextyesButton label.
typestringyesupdate, create, core-action, service or mail.
entitystringyesThe entity the action runs on. nexxus refuses an action without an existing entity.
iconstringnoMaterial Symbols name.
setobjectfor updateField values to set on the record.
creates, valuesstring, objectfor createThe entity to create and its field values.
coreAction, scope, input, storefor core-actionRuns 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, asyncfor serviceCalls an operation of a module service. With async: true nexxus answers right after the service accepted the call.
mail, log, onSent, onFailedfor mailSends the record by e-mail, see Mail actions.
permissionstringnoAn extra permission of the module. Without it the action needs the entity’s write permission.
whenobjectnoThe action is offered and allowed only when the condition holds.
confirmtextnonexxus asks this question before running the action.
aiToolbooleannoThe AI Assistant may propose this action in the chat.
distinctlistnoAt 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.

TemplateBecomes
{{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, $nowToday’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 shows that pattern.

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.

{
"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"}}
}
FieldRequiredMeaning
scopeyesAlways core:mail:send, and the scope stands under scopes.
mail.toyesA reference field of the action’s entity with target core:partner, not multiple. The mail goes to that contact’s e-mail address.
mail.subjectyesThe subject, with placeholders. One line.
mail.textSettingyesA 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.replyToSettingnoA setting of type string with the address replies go to.
mail.localenode (the default), en or pl: the language the values in subject, mail text, file name and folders read in.
mail.pdf.templatefor a PDFA template of the package, templates/<name>.html or just <name>.
mail.pdf.fileNamefor a PDFThe 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.folderfor a PDFThe folder path in Files, one name per entry, with placeholders. A / inside a value becomes -, so 2026/0184 names the folder 2026-0184.
log.entityyesThe entity of the package that records each dispatch.
log.valuesnoFurther values for the dispatch record, with the templates of create.
setnoSet on the record when the mail is queued.
onSent.set, onFailed.setnoSet 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:

PlaceholderBecomes
{{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.

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

  1. PDF

    nexxus fills the template and has it rendered as PDF, without JavaScript.

  2. Files

    The PDF lands in the folder path, where the person may edit.

  3. Queue

    The mail is queued with the PDF attached and the reply-to address.

  4. Record

    A dispatch record with status in_warteschlange, then set on the record.

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 OperationsMaintenanceEmail 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:

FieldTypeFilled
adressestringthe address the mail went to
dokumentref to core:dmsDocumentthe PDF, when the action has one
statusenum with at least in_warteschlange, versendet, fehlgeschlagenas described above
message_idstringafter sending
fehlertextafter a final failure
ausgeloest_vonref to core:userthe person who sent
ausgeloest_am, versendet_amdatetimewhen 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": [
{"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.

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.

ScopeAllows
core:partner:readRead partners
core:partner:createCreate contacts, through a core-action partner.create
core:user:readRead users
core:dmsDocument:readRead documents in Files
core:dmsDocument:createStore files a view uploads (nx.files.upload, for example photos from a tablet) as documents in Files, see Views
core:ismsMeasure:read, core:ismsMeasure:createRead and create ISMS measures
core:partnerContact:readRead contact persons
core:partnerContact:createCreate a contact person of a contact, through a core-action partnerContact.create; nexxus sets Active and ISMS recipient itself when they are left out
core:project:readRead projects
core:invoice:readRead invoices
core:invoice:createCreate draft invoices with one position, through a core-action invoice.createDraft; a person issues them
core:opportunity:readRead opportunities
core:opportunity:createCreate opportunities, through a core-action opportunity.create; without one it gets the first open stage and EUR. Needs the Sales area
core:mail:sendSend e-mails on behalf of your organisation, through a mail action

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": [
{"key": "rechner", "label": {"de": "Rechner", "en": "Calculator"},
"url": "http://localhost:8390/rechner",
"events": ["besuch.created"], "operations": ["pruefen"]}
]
FieldTypeRequiredMeaning
keystringyes
labeltextyesShown to the administrator and in error messages.
descriptiontextno
urlstringyeshttp:// or https://. The operator of the instance can point it elsewhere.
eventslistnoChanges the service receives: <entity>.created, .updated, .deleted of the package’s own entities.
operationslistnoOperations that service actions may call.

How services are called, authenticated and deployed is described in Module services.

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.

"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)"}}
]
"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}})."}}
]}
]
}
FieldMeaning
aiTools.entitiesPer 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.actionsThe AI Assistant may propose each listed action. "aiTool": true on the action does the same. Never for a mail action.
assistant.instructionsInstalled as a skill of the tenant, always on, switched off while the module is deactivated and removed when it is uninstalled.
assistant.promptsExample 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.avatarsUp 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.skillsUp 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[].buttonsUp 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 (AdministrationOperationsSystem 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.

A template replaces the whole start page layout in one click (see 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.

"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"}
]}
]
FieldMeaning
keyUnique in the list.
labelThe template’s name, at most 60 characters per language.
descriptionOptional, at most 300 characters.
iconOptional, a Material Symbols name. Without one, the package’s icon.
rolesOptional, keys of the package’s roles whose holders start on this template. A role names at most one template.
tiles1 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[].viewA view of this package with slot dashboard.widget.
tiles[].widgetA built-in tile without settings: ask (Ask the assistant), onboarding (Setup), ai-sessions (AI Gateway), users (Users) or calendar (Calendar). Report tiles and embedded views are not allowed, because their settings point at the tenant’s own data.
tiles[].widthOptional, 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 Standard, KI and Leer, with the module’s name beside the tile count, for example 4 widgets · Module Loan equipment. 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 Calendar 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 Standard, and the card reads Start page for Equipment management. 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": {
"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.

A package works with its own entities, with core data (the core: targets above) 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):

WhereRefusedMessage after the place
requiresa package key„requires nennt das Paket ‘muster.akten’. Was zusammengehört, gehört in ein Paket; requires nennt nur Kernmodule“
scopespkg:<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. The CuriaOS case study shows a package that grew out of two.

  1. Archive

    ZIP readable, at most 2 000 files and 64 MB unpacked, safe paths.

  2. Schema

    module.json against the package schema: types, required fields, no unknown fields.

  3. Rules

    References between parts, keys, ranges, files, changelog heading.

  4. Hashes and signature

    MANIFEST.sha256 matches, the signature is valid or the package counts as unsigned.

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):

RuleMessage
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“
A refused upload with the list of problems
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 “<file>” was not accepted., 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.