Modules (NXM)
Module services
Code of an NXM module that runs outside nexxus. How nexxus calls a module service, how the service calls back with a short-lived token, and how to build, deploy and connect one.
Declarations and views cover most of what a module does. Some work needs real code: computing a legal deadline with the holidays of a German state, reacting when a record changes, reading the text of an incoming PDF. That code never runs inside nexxus. It runs in a module service, an HTTP service of its own that the package declares and nexxus calls.
The rule behind this is simple. A package comes from outside the platform, so its code stays outside the platform process. nexxus sends the service what it needs together with a signed token that is valid for one minute, and the service calls back through the same record API as everyone else, with exactly the rights the administrator agreed to at install. The service holds no passwords and no API keys.
The Fristendienst of the CuriaOS case study is a real example: it computes deadlines, adds early warnings and proposes deadlines it finds in court letters.
When a module needs a service
Section titled “When a module needs a service”Reach for a service when:
- an action needs logic that
update,createand templates cannot express, such as a calculation with its own rules, - the module must react to changes that people, actions or the AI Assistant make, for example to keep a summary field up to date,
- the work needs a library that a view cannot load, such as a PDF text extractor,
- the work runs in the background without a person on the page.
If a view or a declarative action does the job, you need no service.
How a call works
Section titled “How a call works”Something happens
A record changes, the installation is installed, updated or activated, or a person runs a service action.
nexxus calls the service
POST to the service with a signed invocation token, valid for 60 seconds.
The service checks
It verifies the token against the public keys of nexxus.
Token exchange
On its first callback it trades the token once for an access token, valid for 10 minutes.
The service calls back
It reads and writes records through the record API. nexxus checks the rights on every request.
In detail, for one call that reads and writes records:
- 1POST /events or /actions/<operation>, Bearer invocation token (60 s)nexxus → Module service
- 2GET /api/module-runtime/.well-known/jwks.json (cached)Module service → nexxus
- 3Check signature, typ, iss, aud, exp and that trigger fits the bodyModule service
- 4POST /api/module-runtime/token: exchange the invocation token, onceModule service → nexxus
- 5Access token for the service account (10 min)nexxus → Module service
- 6GET, POST, PATCH /api/x/<module>/<entity> with the access tokenModule service → nexxus
- 7Check the service account, the consented scopes and, if set, the personnexxus
- 8Answer 2xx: the event counts as delivered, an action gets its resultModule service → nexxus
Declaring a service
Section titled “Declaring a service”A service is part of the manifest. This is the real declaration of the Fristendienst from the package curiaos.kanzlei:
"services": [ { "key": "fristendienst", "label": { "de": "Fristendienst", "en": "Deadline service" }, "description": { "de": "Berechnet Fristen ...", "en": "Computes deadlines ..." }, "url": "http://localhost:8390/fristendienst", "events": ["frist.created", "frist.updated", "frist.deleted", "aktendokument.created"], "operations": ["berechnen", "eintragen"] }]| Field | Type | Required | Meaning |
|---|---|---|---|
key | string | yes | Name of the service within the package, e.g. fristendienst |
label | {de, en, pl?} | yes | Shown in the catalogue and in error messages |
description | {de, en, pl?} | no | What the service does |
url | string, http(s):// | yes | Default address. An instance can override it (see below) |
events | string list | no | Record events the service receives: <entity>.created, .updated, .deleted of the package’s own entities. A service hears no other package |
operations | string list | no | Operations that type: "service" actions may call |
An action calls an operation like this:
{ "key": "frist-berechnen", "entity": "frist", "type": "service", "service": "fristendienst", "operation": "berechnen", "label": { "de": "Frist berechnen", "en": "Compute deadline" }, "aiTool": true }Add "async": true when the operation takes longer: nexxus then answers the person right after the service accepted the call, and the person sees "<action>" is running. The result appears once the service has finished.
The core data the service may touch outside its own module goes into scopes, for example core:dmsDocument:read. The administrator agrees to every scope and to every service with its host when installing or updating the package. The full scope list is on Package structure.

What nexxus sends
Section titled “What nexxus sends”Every call is a POST with Authorization: Bearer <invocation token> and a JSON body. Every body carries the receiving installation with its version and its settings. settings holds only the keys the manifest declares under settings. Any 2xx answer counts as success.
Record events
Section titled “Record events”POST <url>/events when a record of a subscribed entity was created, changed or deleted:
{ "id": 4711, "type": "frist.updated", "module": "curiaos.kanzlei", "entity": "frist", "recordId": 42, "data": { "titel": "Berufungsfrist", "faellig_am": "2026-11-16", "status": "offen" }, "occurredAt": "2026-10-09T08:15:00Z", "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }}module is the receiving package itself: a service hears only about records of its own package. type is always <entity>.<op> without the package prefix.
Lifecycle events
Section titled “Lifecycle events”POST <url>/events when the installation was installed, updated or activated, without any subscription:
{ "id": 4712, "type": "installation.installed", "module": "curiaos.kanzlei", "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }, "occurredAt": "2026-10-09T08:20:00Z"}The types are installation.installed, installation.upgraded and installation.activated. They let a service catch up with what it would have done had it been there from the start, for example fill in a computed field for every existing record. previousVersion is set on updates and when a removed installation is installed again.
Actions
Section titled “Actions”POST <url>/actions/<operation> when a person or the AI Assistant runs a type: "service" action:
{ "action": "frist-berechnen", "recordId": 42, "record": { "id": 42, "module": "curiaos.kanzlei", "entity": "frist", "data": { "...": "..." }, "version": 3 }, "payload": { "ereignisdatum": "2026-10-01", "regel": "berufung" }, "installation": { "id": 7, "version": "1.0.0", "settings": { "bundesland": "by", "vorfrist_werktage": 3 } }}The JSON your service answers becomes the action’s result. nexxus waits up to 30 seconds. To refuse, answer with a 4xx status and a JSON object { "errorCode": "COMPOSE_STALE", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." } }; every key is optional, but send at least a code or a text. nexxus answers the person with 422 SERVICE_REJECTED and passes your code and status on beside it as serviceCode and serviceStatus, so a view can react to COMPOSE_STALE without reading the text. The text is in the person’s language: with messages your own text in that language (else English, else German) and nothing in front; with one message only, nexxus puts „Der Dienst „<Label>“ hat die Aktion abgelehnt:“ in front of it, or “The service “<Label>” refused the action:” or „Usługa „<Label>” odrzuciła akcję:“. Texts arrive as plain text, at most 300 characters each.
Public endpoints
Section titled “Public endpoints”POST <url>/portal/<key> when a visitor of the tenant’s customer portal uses an endpoint that the package declares under portal.endpoints with this service. The visitor is anonymous: the token carries no principal, and its trigger is portal:<key>, so a token issued for one endpoint cannot call another. nexxus forwards only the declared params (record as a number, the others as the visitor sent them) and for a POST the form, which it has already checked (types, required fields, e-mail address, consent, honeypot, rate limit); a GET sends form: null. Before forwarding it also checks that record is a public record. locale is the language of the portal page, de, en or pl. A GET endpoint reaches the service as a POST too. nexxus waits up to 10 seconds.
{ "endpoint": "quote", "method": "GET", "params": { "record": 12, "check_in": "2026-11-12", "check_out": "2026-11-16", "guests": "4" }, "form": null, "locale": "en", "installation": { "id": 7, "version": "1.2.0", "settings": { "currency": "ZAR", "time_zone": "Africa/Johannesburg" } }}Check everything again: the call comes from the internet. The JSON of a 2xx answer (at most 64 KB) goes to the visitor as it is, so answer only with what the site may show. A refusal the visitor should read is a 400, 409 or 422 with { "errorCode": "...", "message": "...", "messages": { "de": "...", "en": "...", "pl": "..." }, "fieldErrors": { "<field>": "..." } } (pl optional), the texts in the page’s language; nexxus passes it on with the same status (plain text, at most 300 characters each). Use 422 for bad input and 409 when the current state does not allow it, for example dates that are taken by now. A record the site does not show nexxus refuses with 404 before it forwards the call. Every other status, and no answer within the time, reaches the visitor as a generic error. The Higgins-Dienst answers the booking site’s availability, quote and booking_request this way.
The tokens
Section titled “The tokens”Invocation token
Section titled “Invocation token”nexxus signs every call with ES256. The header carries kid and typ: nexxus-module-it. The public keys are at GET <nexxus>/api/module-runtime/.well-known/jwks.json, without a session.
| Claim | Meaning |
|---|---|
iss | nexxus |
aud | The module key, e.g. curiaos.kanzlei |
sub | module:<module key> |
tenant | Id of the tenant |
inst | Id of the installation |
ver | Installed version of the module |
trigger | What caused the call: event:<type>, lifecycle:<type> or action:<key> |
principal | {userId, name} of the person who ran the action; absent for events |
jti, iat | Token id and issue time |
exp | iat + 60 seconds |
Check typ, iss, aud, sub and exp, and check that trigger fits the call: an event body with type frist.updated needs event:frist.updated. That way a token for one call cannot be replayed for another. nexxus accepts only ES256 and tolerates 5 seconds of clock difference.
Exchanging it for an access token
Section titled “Exchanging it for an access token”On its first callback the service trades the invocation token for an access token (RFC 8693):
POST /api/module-runtime/tokenContent-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=<invocation token>&subject_token_type=urn:ietf:params:oauth:token-type:jwt{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 600, "issued_token_type": "urn:ietf:params:oauth:token-type:access_token"}The endpoint also takes the same fields as JSON. Each invocation token can be exchanged once, and only while the installation is active. Errors follow RFC 6749: status 400 with {error, error_description} and error invalid_grant, invalid_request or unsupported_grant_type.
| Access token claim | Meaning |
|---|---|
typ (header) | nexxus-module-at |
aud | nexxus |
sub | Id of the installation’s service account |
act | {sub: "module:<module key>"} |
tenant, inst | Tenant and installation |
principal | Copied from the invocation token, if a person acted |
scope | The consented scopes, separated by spaces |
exp | At most 10 minutes after issue |
nexxus accepts the access token only on /api/x/** (records and actions), /api/module-runtime/lookup and /api/module-runtime/documents/**. Anywhere else it answers 401 with „Ein Modul-Token gilt nur für die Modul-Schnittstelle.“ (a module token is only valid for the module interface).
What a service may do
Section titled “What a service may do”A service acts as the service account of its installation. nexxus creates that account at install, one per installation, with a role that holds every permission of the module. The account cannot sign in, and nexxus switches it off while the module is deactivated or uninstalled. Records the service writes name it as their creator. It is named in the tenant’s default language, the module’s name followed by „(module service)“ in English, „(Moduldienst)“ in German or „(usługa modułu)“ in Polish, and nexxus names it anew on every install, upgrade, activation and reinstall. Its role is named the same way.
| Data | Access |
|---|---|
| Records of its own module | Read and write, including readonly fields |
| Core data (partners, users, documents, ISMS measures) | Only within the consented core: scopes |
| Documents (PDF bytes) | With core:dmsDocument:read, and only documents that a record of its own module references |
If a person ran the action, the token carries that person as principal, and nexxus checks the person’s rights as well. Both must pass. Event deliveries carry no person.
The service kit
Section titled “The service kit”The nexxus sources contain a kit for module services under module-services/: a Bun and TypeScript process that hosts one route group per service. It verifies tokens, binds them to the call, exchanges them on the first callback and logs every request without tokens or bodies. You write the handlers.
A service is a ModuleServiceDefinition:
| Member | Meaning |
|---|---|
name | Folder name and path prefix: rueckgabe serves POST /rueckgabe/events, POST /rueckgabe/actions/<operation> and POST /rueckgabe/portal/<key> |
moduleKey | The module the service belongs to; the token’s aud must name it |
events | Handler for record events |
lifecycle | Handler for installation.installed, .upgraded and .activated |
actions | Handlers by operation, e.g. { zaehlen } |
portal | Handlers of public portal endpoints by key, e.g. { quote }. A handler may answer with a Response of its own to set the status of a refusal |
Each handler gets {claims, body, log, platform}. platform is a PlatformClient that calls back with the access token:
| Method | Does |
|---|---|
search(entity, {q?, ids?, filter?, sort?, from?, size?}) | One page of records, {items, total}. ids keeps only these records, at most 200; more throw before any call |
searchAll(entity, query?) | Every matching record, page by page (200 per page, sorted by id). Takes any number of ids and asks for them 200 at a time |
get(entity, id) | One record |
create(entity, data) | Creates a record |
patch(entity, id, data, version?) | Changes fields; with version, a newer record answers CONFLICT |
documentContent(documentId) | The bytes of a referenced document |
entity is one of the module’s own; the kit reaches no other package. A failed platform call throws a PlatformError with status, errorCode and message. Throw an HttpError(status, errorCode, message) to answer with an error of your own.
A small service
Section titled “A small service”This service belongs to the acme.leihgeraete package from the first module tutorial. It gives every lent device without a return date one 14 days ahead, and it offers an operation that counts overdue devices.
import type { ModuleServiceDefinition } from '../kit/service.ts';
export function rueckgabedienst(): ModuleServiceDefinition { return { name: 'rueckgabe', moduleKey: 'acme.leihgeraete',
async events({ body, platform, log }) { if (body.type !== 'geraet.updated') return { ok: true, ignoriert: body.type }; // Events can arrive late or twice: read the current record instead of trusting body.data. const geraet = await platform.get('geraet', body.recordId); if (geraet.data.status !== 'verliehen' || geraet.data.rueckgabe_am) return { ok: true }; const due = new Date(Date.now() + 14 * 86_400_000).toISOString().slice(0, 10); await platform.patch('geraet', geraet.id, { rueckgabe_am: due }, geraet.version); log.info('return date set', { recordId: geraet.id }); return { ok: true }; },
actions: { async zaehlen({ platform }) { const today = new Date().toISOString().slice(0, 10); const { total } = await platform.search('geraet', { filter: { status: 'verliehen', rueckgabe_am: { to: today } }, size: 1, }); return { faellig: total }; }, }, };}Add it to the list in src/main.ts next to the other services, and declare it in the manifest:
"services": [{ "key": "rueckgabe", "label": { "de": "Rückgabedienst", "en": "Return service" }, "url": "http://localhost:8390/rueckgabe", "events": ["geraet.updated"], "operations": ["zaehlen"]}],"actions": [{ "key": "faellige-zaehlen", "entity": "geraet", "type": "service", "service": "rueckgabe", "operation": "zaehlen", "label": { "de": "Fällige zählen", "en": "Count due" }}]The service’s own patch does not come back to it as an event, so it cannot trigger itself.
Run it
Section titled “Run it”The kit needs one environment variable and holds no secrets:
| Variable | Default | Meaning |
|---|---|---|
PLATFORM_URL | required | Base URL of the nexxus backend as the service reaches it, without a trailing slash. JWKS, token exchange and record API are read from it |
PORT | 8390 | Port the service listens on |
bun installPLATFORM_URL=https://nexxus.example.com bun dev # watch modebun testbun run typecheckGET /health answers {"status": "UP", "services": [...]} without a token. The folder contains a Dockerfile that runs the kit as a non-root user. Only nexxus needs to reach the service, and the service needs to reach nexxus.
You don’t have to use the kit. Any HTTP service in any language works if it verifies the invocation token as described above, exchanges it and calls the record API.
Connecting a service on an instance
Section titled “Connecting a service on an instance”The manifest’s url is a default, and localhost rarely is where the service runs. nexxus takes the first usable address from:
- the operator configuration of the instance,
- the installation setting
serviceUrls.<service>, - the manifest’s
url.
Only absolute http and https URLs count; anything else falls through to the next source.
When the manifest’s url points at localhost or 127.0.0.1 and the instance sets nexxus.modules.local-service-base-url, nexxus replaces scheme, host and port with that address and keeps path and query. When all services of an instance run behind one address, such as http://module-services:8390, no service needs a line of its own (environment variable NEXXUS_MODULES_LOCALSERVICEBASEURL). The consent counts the host after the replacement. The installation setting stays as it is, even when it names localhost.
nexxus: modules: service-urls: "[acme.leihgeraete.rueckgabe]": http://rueckgabe:8390/rueckgabeThe key is <module key>.<service key> in brackets, because module keys contain a dot. As an environment variable the same key is NEXXUS_MODULES_SERVICEURLS_ACME_LEIHGERAETE_RUECKGABE. Through the API, an administrator can set the installation setting with PATCH /api/module-installations/<key>/settings and {"settings": {"serviceUrls": {"rueckgabe": "https://..."}}}.
The consent belongs to the host. The administrator agrees to service:<key>@<host> (with the port when the URL names one), shown in the install dialog as Call the service "<label>" at <host> and send it events. Pointing a service at another host therefore needs a new consent. When an administrator sets the URL through the installation settings, that change counts as the consent. Changing the host in the operator configuration has the same effect on the consent: until it matches, nexxus refuses the service.
Delivery, retries and the deliveries card
Section titled “Delivery, retries and the deliveries card”nexxus writes every event to an outbox in the same transaction as the change, so no event is lost when a change is saved, and no event goes out for a change that was rolled back.
- A dispatcher sends due events every 2 seconds, with a timeout of 10 seconds.
- Delivery is at least once and not ordered. A retried event can arrive after a later one.
- After the n-th failure the event waits 10 s × 2n−1 (10, 20, 40, 80, 160, 320, 640 seconds). The 8th failure marks it as failed, about 21 minutes after the first attempt.
- If the installation is no longer active or the service is no longer consented, the event fails at once with „Installation nicht aktiv“ (installation not active) or „Dienst „<key>“ ist nicht freigegeben“ (service not released).
- A service receives no events for its own writes. It must do any follow-up work in the same handling.
- During an update, every migration step sends one event per record it changes, before
installation.upgraded. See Data and upgrades. - nexxus keeps delivered and failed events for 30 days.
So write handlers that can run twice: use the event id to recognise a repeat, read the current record instead of trusting data, and only write values that differ.
Administrators see the deliveries on the package page under AdministrationOperationsModules, tab Packages, in the card Event deliveries: the last 100 events with Event, State (Waiting, Delivered or Failed), the number of attempts, the last error and Created. Refresh reloads the list, and the card also refreshes itself every 5 seconds.
When something goes wrong
Section titled “When something goes wrong”These are the messages a person sees when a service action fails:
| Message | Cause | Fix |
|---|---|---|
| „Der Dienst „<Label>“ ist für diesen Mandanten nicht freigegeben. Eine Administratorin oder ein Administrator muss ihm unter System › Module zustimmen.“ | The installation’s consent does not contain the service at its current host | An administrator gives the consent again: setting the service URL in the installation settings counts as consent, and the update dialog of a new version asks for it |
„Der Dienst „<Label>“ hat die Aktion abgelehnt: <message>“, in English or Polish for people who work in those languages, or the service’s own text from messages | The service answered 4xx with a message or messages | Read the message; it is the service’s own reason |
| „Der Dienst „<Label>“ hat die Aktion abgelehnt (<code>).“ | The service answered 4xx with an errorCode and no text | Send a message or messages with the code, so the person learns why |
| „Der Dienst „<Label>“ hat die Aktion abgelehnt (HTTP <status>).“ | The service answered 5xx, or 4xx with neither a code nor a text | Fix the error in the service, or answer a refusal with JSON {"errorCode": "...", "message": "..."} |
| „Der Dienst „<Label>“ ist nicht erreichbar.“ or „Der Dienst „<Label>“ wurde nicht erreicht.“ | No connection, or no answer within 30 seconds | Check that the service runs and that the effective URL reaches it |
| „Der Dienst „<Label>“ hat keine lesbare Antwort geliefert.“ | The answer was not JSON | Answer with JSON, or with an empty body |
For events, look at the deliveries card: the last error of each event is in the State column. Check that the service reaches PLATFORM_URL and that its clock is correct, because both tokens are checked against their expiry time. A service that checks tokens against the keys of another nexxus instance answers 401 with kid unknown: its PLATFORM_URL points to the wrong platform.

Configuration
Section titled “Configuration”| Key | Default | Meaning |
|---|---|---|
nexxus.modules.service-urls."[<module>.<service>]" | none | Where a module service runs |
nexxus.modules.local-service-base-url | none | Replaces localhost and 127.0.0.1 in the manifests’ url, for example with http://module-services:8390 |
nexxus.modules.tokens.invocation-ttl-seconds | 60 | Lifetime of an invocation token |
nexxus.modules.tokens.access-ttl-seconds | 600 | Lifetime of an access token |
nexxus.modules.tokens.clock-skew-seconds | 5 | Tolerated clock difference |
nexxus.modules.events.dispatcher-enabled | true | Switch off only on instances that must not deliver |
nexxus.modules.events.dispatch-interval-ms | 2000 | How often due events go out |
nexxus.modules.events.batch-size | 20 | Events per round |
nexxus.modules.events.max-attempts | 8 | Attempts before an event fails |
nexxus.modules.events.backoff-base-seconds | 10 | Delay after the n-th failure: base × 2n−1 |
nexxus.modules.events.timeout-seconds | 10 | Timeout per delivery |
nexxus.modules.events.retention-days | 30 | How long delivered and failed events are kept |
nexxus.modules.actions.timeout-seconds | 30 | Timeout of a service action |
nexxus creates the signing keys for the tokens on first start and stores them encrypted in its database, shared by all instances of the platform.
Known limits
Section titled “Known limits”- One exchange, ten minutes. An invocation token can be exchanged once, and the access token cannot be renewed. One call may work with nexxus for 10 minutes at most.
- Long handlers count as failed. An event handler that takes longer than 10 seconds counts as a failed attempt, even if it finishes later. Keep every write idempotent so the retry finds the work done.
- One replica of the kit. The kit remembers delivered event ids in memory. Two replicas could do the same work twice.