# Program apps

> The reference for developers who write an app themselves: the .nxa package and its manifest, the .nxd document format, what an app may do inside the sandbox, the message protocol between app and nexxus, how the AI Assistant and co-editors change your documents, and how to test and publish.

:::note[No code needed?]
To create documents, install a .nxa file or let the AI Assistant create an app for you, see [Create apps without code](/apps/create/). This page is for developers who write an app's HTML themselves.
:::

An app is one HTML file and one description file. The HTML draws a document and reports the user's edits. Everything else is done by nexxus: loading and saving the document, versions, sharing, live co-editing, the assistant, permissions. Your app never touches storage and never sees the network. That keeps apps small, and it is why nexxus lets anyone install them.

This page is the contract. It describes what ships in nexxus today.

<Hub
  inputsLabel="nexxus"
  inputs={[
    { icon: 'folder-open', title: 'Files', text: 'Loads and saves the .nxd document, keeps its versions' },
    { icon: 'users', title: 'Co-editors', text: 'Their changes arrive while the app is open' },
    { icon: 'sparkles', title: 'AI Assistant', text: 'Writes schema-checked changes to the data' },
  ]}
  core={{
    title: 'Your app',
    text: 'app.html in a sandboxed frame, no network, no storage',
    items: ['nxd:init', 'nxd:apply', 'nxd:change', 'nxd:export'],
  }}
  outputsLabel="Back to nexxus"
  outputs={[
    { icon: 'pencil', title: 'Edits', text: 'The whole new data with every committed change' },
    { icon: 'download', title: 'Files to save', text: 'An image, an HTML page, a JSON export' },
    { icon: 'message-square-text', title: 'Questions for the AI Assistant', text: 'The element the user points at' },
  ]}
  caption="The app only draws data and reports edits. All messages travel over a private channel between nexxus and the frame."
/>

## The package (.nxa)

An app is shipped as a ZIP file with the ending `.nxa`. nexxus reads two entries from it, found by their file names anywhere in the archive:

<FileTree>
- hello.nxa a ZIP archive
  - manifest.json id, version, label, icon, content type, data schema
  - app.html the renderer, everything inline
</FileTree>

Other entries are ignored. Build the package with any ZIP tool, for example:

```bash
zip -j hello.nxa manifest.json app.html
```

The in-app help has a working example: on <MenuPath to="Start > Apps" />, <Ui>Help</Ui> and then <Ui>Download example app</Ui> save `hello.nxa`. Every download carries a fresh ID such as `hello-ab12c`, so the example can be installed more than once in a tenant.

<Screenshot src={shot_apps_build_help_example} url="/dms-apps" alt={"The help dialog with the package structure and the example download"} caption={"The help explains the package and offers a working example app."} />

## The manifest

`manifest.json` describes the app. nexxus checks it completely before it stores anything, on upload, on a new version and when the AI Assistant registers an app.

This is the manifest of the example app, as nexxus generates it:

```json
{
  "id": "hello-ab12c",
  "version": 1,
  "label": "Hallo-App",
  "icon": "waving_hand",
  "contentType": "application/json",
  "dataKind": "json",
  "schema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "text": { "type": "string", "description": "Der angezeigte Text." }
    },
    "required": ["text"]
  }
}
```

### Field reference

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string | yes | The app's ID, and the `$app` value of its documents. 2 to 40 characters, lowercase letters, digits and hyphens, starting with a letter (`[a-z][a-z0-9-]{1,39}`). Unique per tenant. `board` and `markdown` are reserved for the built-in apps. |
| `label` | string | yes | The name on the card, in the <Ui>New</Ui> menu of Files and on the document page. |
| `contentType` | string | yes | How the data is described, for example `application/json`, `text/markdown`, `text/plain` or `application/xml`. |
| `dataKind` | `"json"` or `"text"` | yes | `json`: `data` is a JSON value checked against `schema`. `text`: `data` is a string. |
| `schema` | object | for `json` apps | A JSON Schema (draft 2020-12) for `data`. nexxus compiles it on upload and checks every document the AI Assistant creates or changes against it. Text apps get `{"type": "string"}`. |
| `description` | string | no | Shown on the app's card and given to the AI Assistant. |
| `icon` | string | no | A Material icon name for the card. Default `apps`. |
| `color` | string | no | The card's colour as hex. Default `#1565C0`. |
| `version` | integer | no | The app's version, default `1`. Every new upload must carry a higher number. |
| `defaultData` | any | no | The content of a new document. Without it, a new document starts with `{}` (json) or an empty string (text), and your app has to render that. |
| `aiInstructions` | string | no | Guidance for the AI Assistant on how to build a good document, at most 8000 characters. |
| `changelog` | array | no | What each version changed, shown before a release and in the version history. |
| `capabilities` | object | no | Opt-ins for the frame around the app: `tabs` and `assistant`. |
| `contributes` | object | no | Menu entries the app adds to the bar above it. At most 256,000 bytes. |

`app.html` must not be empty and may be at most 2,000,000 bytes (UTF-8).

### The data schema

The schema is the contract between your app, nexxus and the AI Assistant. Three things follow from it:

- nexxus rejects any document from the AI Assistant that does not match. The AI Assistant gets the error message and can correct its change.
- The AI Assistant learns your data model from the schema. Put a `description` on every property. That is where the AI Assistant reads what a field means, which units it uses and which values make sense.
- Documents outlive app versions. Add new properties as optional, so documents created with an older version stay valid.

Keep `defaultData` empty or minimal. When the AI Assistant is asked to build something in a new document, it is told to replace unchanged starting content. A demo board that never goes away is still the most common complaint about starting content. Put onboarding into your app's empty state instead. nexxus does not check `defaultData` against the schema on upload, but the AI Assistant's create tool does, so a `defaultData` that breaks the schema makes it fail on new documents.

### aiInstructions

The schema says what is valid. `aiInstructions` says what is good: layout rules, sizes, conventions, the mistakes models tend to make with your data. nexxus gives the text to the AI Assistant together with the schema whenever someone works on one of your documents, and returns it from the app list tool. On the app's page, the owner can add an addendum for their tenant; the AI Assistant gets your text first, then the addendum.

### changelog

One entry per version, newest first. `version` is an integer of at least 1, `changes` a non-empty list of non-empty strings, `date` optional in the form `YYYY-MM-DD`. Other keys are ignored. These are the two newest entries of the Whiteboard app:

```json
"changelog": [
  {
    "version": 7,
    "date": "2026-10-04",
    "changes": [
      "Die App bringt einen Changelog mit. Er erscheint beim Hochladen einer neuen Version und in der Versionshistorie.",
      "Die Hinweise für den KI-Assistenten erklären jetzt auch Standardgrößen, wie das Board überlappende Elemente nachbessert, Frames als Spalten und typische Fehler wie doppelte ids."
    ]
  },
  {
    "version": 6,
    "date": "2026-10-03",
    "changes": [
      "Hell und Dunkel folgen dem Modus der Anwendung, in der das Board läuft.",
      "Wer in der App selbst umschaltet, behält diese Wahl für das Whiteboard."
    ]
  }
]
```

When someone uploads a new version, the dialog lists every entry newer than the installed version, up to the package's. The version history on the app's page takes each version's entry from the newest uploaded manifest that has one, so a full changelog in a new release fills in older versions too. Write the bullets for the people who use the app: one concrete change each.

### capabilities

| Key | Type | Default | Effect |
|---|---|---|---|
| `tabs` | boolean | `false` | nexxus shows a row of tabs above the app. The opened document is the first tab, <Ui>+</Ui> opens a picker with the other documents of the same app and <Ui>Blank document</Ui>. Switching tabs loads the app afresh with the other document. |
| `assistant` | boolean | `true` | `false` declares that the app's documents are not meant for the AI Assistant. The switch <Ui>Assistant for this app</Ui> on the app's page then stays off and cannot be turned on until a version allows it. |

Unknown keys are ignored. Absent means off, except for `assistant`.

### contributes

An app can add commands to the bar above it, either into the standing <Ui>File</Ui> menu or as menus of its own. A click is sent to the app as `nxd:command` with the command's ID. This is the block of the BPMN process editor:

```json
"contributes": {
  "commands": [
    { "id": "process.validate", "title": "BPMN prüfen", "category": "Prozess" },
    { "id": "process.export.svg", "title": "Als SVG exportieren", "category": "Datei" }
  ],
  "menus": {
    "datei": [ { "command": "process.export.svg", "group": "export" } ],
    "menubar": [
      { "id": "prozess", "title": "Prozess", "items": [ { "command": "process.validate" } ] }
    ]
  }
}
```

- `commands` declares commands with `id` and `title`. The first declaration of an ID wins. The IDs `__proto__`, `constructor` and `prototype` are refused.
- `menus.datei` appends items to the <Ui>File</Ui> menu, grouped by `group` and sorted by `order`. `datei` is the only standing menu an app can extend.
- `menus.menubar` adds menus of its own, each with `id`, `title`, `items` and an optional `order`. At most 8 menus with 24 items each are shown.
- nexxus never rejects a manifest for a bad menu entry. It drops what it cannot resolve (an unknown command, a menu it does not allow) and warns in the browser console. It only relays command IDs that survived this check.

## The document (.nxd)

A document is a JSON file in Files, wrapped in a small envelope:

```json
{
  "$app": "board",
  "$v": 1,
  "contentType": "application/json",
  "meta": { "title": "Messe 2026" },
  "data": {
    "columns": [ { "id": "todo", "title": "To-do" } ],
    "cards": []
  }
}
```

| Field | Type | Meaning |
|---|---|---|
| `$app` | string | The ID of the app that opens the document. Required: without it nexxus shows <Ui>Invalid .nxd file: field "$app" is missing.</Ui> |
| `$v` | number | Version of the envelope format, currently `1`. |
| `contentType` | string | Copied from the manifest. |
| `meta.title` | string | The document's title. It is also part of the file name. |
| `data` | object or string | Your app's content: what `schema` describes for json apps, a string for text apps. |

The file name is `<title>.<app ID>.nxd`, for example `Messe 2026.board.nxd`. nexxus reads the app ID from the file name when the document is created and uses it to find the app's documents. Which app opens a document is decided by `$app`, not by the name. A file that is not valid JSON shows <Ui>Invalid .nxd file: not valid JSON.</Ui>

Your app only ever sees `data` (and the title in `meta`). The envelope is nexxus's business.

## Inside the sandbox

nexxus loads `app.html` into an iframe with `sandbox="allow-scripts"`: your code runs, but without the origin of nexxus, so it has no cookies, no session and no access to the page around it. In front of your HTML, nexxus puts this content security policy:

```text
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;
font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'
```

What that means for your code:

| Works | Does not work |
|---|---|
| Inline `<script>` and `<style>`, everything in the one HTML file | Any network request: `fetch`, WebSocket, external scripts, stylesheets, web fonts, images from a URL |
| Images and fonts as `data:` or `blob:` URLs | `localStorage`, `sessionStorage`, cookies (the frame has no origin) |
| `<input type="file">`, paste and drag and drop | `alert`, `confirm`, `prompt` (blocked in the sandbox): build your own dialogs |
| Canvas, SVG, everything the browser draws | Downloading a file directly: hand it to nexxus with `nxd:export` |

The document's data is your only state. Pictures a user adds belong in `data`, compressed in the browser, and large amounts of raster data belong in Files as their own documents.

## The protocol

The app and nexxus talk over a private `MessageChannel`. The handshake has three steps, then messages flow both ways over the port.

<Sequence
  actors={[
    { name: 'Your app', icon: 'app-window' },
    { name: 'nexxus page', icon: 'monitor' },
    { name: 'nexxus server', icon: 'server' },
    { name: 'Co-editor', icon: 'users' },
  ]}
  steps={[
    { from: 0, to: 1, label: 'nxd:ready (to the parent window)' },
    { from: 1, to: 0, label: 'nxd:connect with a MessagePort' },
    { from: 1, to: 0, label: 'nxd:init: data, title, readonly' },
    { from: 0, to: 0, label: 'The person edits' },
    { from: 0, to: 1, label: 'nxd:change with the whole data' },
    { from: 1, to: 2, label: 'Live to the others, saved after about 1.5 s' },
    { from: 2, to: 3, label: 'The change arrives at the co-editor' },
    { from: 3, to: 2, label: 'The co-editor changes something' },
    { from: 2, to: 1, label: 'Relayed to this page' },
    { from: 1, to: 0, label: "nxd:apply with origin 'peer'" },
  ]}
  caption="After the handshake every message goes over the port. Your app never talks to the server itself."
/>

### Messages

| Direction | Message | Meaning |
|---|---|---|
| app to nexxus (window) | `{type: 'nxd:ready'}` | The app has booted and wants its channel. |
| nexxus to app (window) | `{type: 'nxd:connect'}` with a `MessagePort` in `ports[0]` | Keep the port, it carries everything else. |
| nexxus to app | `{type: 'nxd:init', doc, meta, readonly, hostCapabilities}` | The document's `data` as `doc`, `meta` with the title, `readonly`, and what this host supports, today `{assist: true}` or `{assist: false}`. |
| nexxus to app | `{type: 'nxd:apply', doc, origin}` | Someone else changed the document. `origin` is `'ai'` for the AI Assistant and `'peer'` for a co-editor. Treat a missing `origin` as `'peer'`. |
| app to nexxus | `{type: 'nxd:change', doc}` | The user changed something. Send the whole new data. Ignored while the document is read-only. |
| nexxus to app | `{type: 'nxd:command', id}` | The user clicked one of your menu entries. |
| app to nexxus | `{type: 'nxd:export', filename, mime, data}` | Save a file for the user. |
| app to nexxus | `{type: 'nxd:assist', context, message}` | Hand the element the user points at to the AI Assistant. |
| app to nexxus | `{type: 'nxd:presenting', on}` | A full-screen show started (`true`) or ended (`false`). |
| nexxus to app | `{type: 'nxd:theme', mode, pref}` | Light or dark: `mode` is the mode of nexxus, `pref` this person's choice for your app. |
| app to nexxus | `{type: 'nxd:theme-pref', pref}` | The person used your app's light or dark switch. |

### A complete minimal app

This is the `app.html` of the example app. It renders one text field, reports every keystroke and takes changes from others:

```html
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><style>
  body{font:15px/1.5 -apple-system,system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh}
  input{font-size:20px;padding:10px 14px;border:1px solid #cbd5e1;border-radius:10px}
</style></head><body>
  <input id="t" placeholder="Text eingeben">
  <script>
    var port=null, state={text:""};
    var t=document.getElementById("t");
    function render(){ if(t.value!==(state.text||"")) t.value=state.text||""; }
    function emit(){ if(port) port.postMessage({type:"nxd:change", doc: state}); }
    t.addEventListener("input", function(){ state.text=t.value; emit(); });
    function handle(m){ if(!m) return;
      if(m.type==="nxd:init"){ state=m.doc||{text:""}; t.readOnly=!!m.readonly; render(); }
      if(m.type==="nxd:apply"){ state=m.doc||{text:""}; render(); } }
    window.addEventListener("message", function(e){
      if(e.data && e.data.type==="nxd:connect" && e.ports[0]){ port=e.ports[0]; port.onmessage=function(ev){handle(ev.data);}; } });
    (window.parent||window).postMessage({type:"nxd:ready"}, "*");
  </script>
</body></html>
```

### Rules every app follows

- **Respect `readonly`.** Hide every editing control when it is `true`: palettes, handles, delete buttons. One CSS class on `<body>` is easier to keep complete than a check per button. Keep reading, searching, zooming and exporting. nexxus enforces write rights on the server and drops `nxd:change` from a read-only document, so `readonly` is about not offering what would fail.
- **Treat every `nxd:init` as a fresh start.** Reset your state from it. Hosts can send it more than once.
- **Do not clobber active input on `nxd:apply`.** When a co-editor's change arrives while the user is typing in a field, keep the user's text in that field. The example's `render()` only writes the field when the value differs.
- **Send `nxd:change` for every committed edit**, with the complete data. Debounce keystroke streams.
- **Read `hostCapabilities` leniently.** Absent means the host cannot. Offer a feature only when the host announces it.

### Saving files: nxd:export

The sandbox cannot download files, so your app hands them to nexxus:

```js
port.postMessage({ type: 'nxd:export', filename: 'board.svg', mime: 'image/svg+xml', data: svgString });
```

`data` may be a string, an `ArrayBuffer`, a typed array or a `Blob`, passed as bytes and never base64. nexxus does not look at the format. It checks:

- The export must follow a real click inside your app. An export without a recent click is dropped.
- At most 50 MB.
- At least one second between exports, at most 20 per minute. Otherwise the user sees <Ui>Too many exports in a short time. Please wait a moment.</Ui>
- `mime` must have the shape `type/subtype`. The file name is reduced to a plain name of at most 200 characters (no paths, no control characters, no leading dots). An invalid message shows <Ui>Export failed: the app delivered invalid data.</Ui>
- On a public link that forbids downloads, the user sees <Ui>Downloads are not allowed for this link.</Ui>

### Asking the AI Assistant about one element: nxd:assist

Your app cannot reach a model. It can say what the user is pointing at:

```js
port.postMessage({
  type: 'nxd:assist',
  context: {
    focus: { kind: 'block', id: 'b-7', label: 'Tabelle',
             path: 'Abschnitt „Liste“ › Tabelle', data: node },
    summary: 'Auftragstabelle mit 4 Spalten',
    excerpt: '<table class="orders">...</table>'
  },
  message: 'Diese Tabelle ist zu breit.'
});
```

nexxus shows the element as the conversation's focus, opens the AI Assistant and puts `message` into the input field without sending it: the user still chooses the model and the wording. The focus travels with every following message until the user moves on. `kind` is yours to define (`block`, `slide`, `entity`). Send markup in `excerpt` rather than describing a picture, because nexxus cannot capture your frame and markup tells a model more.

Everything crossing the sandbox is treated as untrusted and cut to size: `label` to 120 characters, `kind` to 40, `path` and `summary` to 300, `excerpt` to 2000 and `data` to 4000 characters of JSON.

Offer the hand-over only when `nxd:init` carried `hostCapabilities.assist === true`. It is `false` where no assistant is available, for example when the assistant is switched off for your app. Without it, show the context for the user to copy, rather than a button that does nothing.

### Light and dark: nxd:theme

Your app's controls (toolbars, side panels, dialogs) should follow nexxus until the person switches inside your app. The content itself keeps its own look. nexxus sends `nxd:theme` after every `nxd:init` and whenever either value changes:

```js
const look = { host: 'light', pref: 'auto' };
const dark = () => (look.pref === 'auto' ? look.host : look.pref) === 'dark';
// on nxd:theme:  look.host = m.mode; look.pref = m.pref; apply()
// own switch:    const next = dark() ? 'light' : 'dark';
//                look.pref = next === look.host ? 'auto' : next;
//                port.postMessage({ type: 'nxd:theme-pref', pref: look.pref }); apply()
```

nexxus keeps the choice per person and app in the person's settings and sends it back with the next `nxd:theme`. Never store it in `data`: it belongs to the person, not to the document. Switching back to the mode of nexxus means `pref: 'auto'`.

### Presenting: nxd:presenting

Send `{type: 'nxd:presenting', on: true}` when your app starts a full-screen show and `on: false` when it ends. nexxus hides the other people's pointers in the meantime and stops reporting the presenter's pointer. Live pointers are drawn by nexxus over your app; you do not need to do anything else for them.

## How the AI Assistant changes your documents

When someone opens the AI Assistant on one of your documents, nexxus tells it once at the start of the conversation:

- that the document is an app document, which app it is, and that its current data arrives with every message;
- your schema (inline when it is at most 20,000 characters, otherwise the AI Assistant fetches it with the app list tool);
- your `aiInstructions` followed by the tenant's addendum;
- to change the document through `nx_dmsapp_update` instead of describing the change, to replace unchanged starting content, and never to create side files instead.

With every message the AI Assistant gets the data as the user sees it at that moment, including unsaved changes and co-editors' edits. Above 100,000 characters the data is cut, and the AI Assistant is told to send a JSON Patch (RFC 6902) with only the changed paths instead of the whole data:

```json
[
  { "op": "replace", "path": "/slides/0/boxes/1/text", "value": "Neu" }
]
```

On the server, nexxus applies the change, checks the result against your schema, checks the person's edit right, saves a new version and sends the data to every open copy of the document with `nxd:apply` and `origin: 'ai'`.

Your app may repair what a model cannot see, such as text that overflows a box or new elements that overlap old ones. Do it only for `origin: 'ai'`, then send the result as your own `nxd:change`. A co-editor's change (`'peer'`) was already drawn the way that person wanted, so leave it as it is.

## How live co-editing works

- Every `nxd:change` is saved about one and a half seconds later and sent at once to everyone else who has the document open, where it arrives as `nxd:apply` with `origin: 'peer'`. Each message carries the whole data; one message may be at most 4 MB.
- A person who opens the document while others work on it gets their current state first.
- Each save names the state it was based on. If someone else saved in between, nexxus fetches their state, merges it with this person's changes and saves again. The merge works on your JSON: objects merge key by key, and arrays whose entries all carry a unique `id` merge entry by entry, so two people who each add a sticky note both keep theirs. Where both changed the same value differently, this person's value wins and the other state is kept as a separate version. A deletion on one side against a change on the other keeps the changed entry.
- A version for the history is created when someone leaves the document, or after ten quiet minutes.

For your app this means: expect `nxd:apply` at any time, also in the middle of a drag or while the user types, and give every entry of a list a stable, unique `id`. Lists without IDs can only be merged as a whole.

## Test your app

You can test the whole protocol without nexxus. This page plays the host: it puts the same content security policy in front of your app, does the handshake, sends `nxd:init` and logs everything the app sends back.

<FileTree>
- my-app
  - manifest.json
  - app.html your app
  - host.html the test host below
</FileTree>

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>DMS app test host</title>
<iframe id="app" sandbox="allow-scripts" style="width:100%;height:60vh;border:1px solid #ccc"></iframe>
<pre id="log"></pre>
<script>
  // The same policy nexxus puts in front of every app: no network at all.
  const CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; " +
              "font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'";
  const seal = (html) => {
    const meta = `<meta http-equiv="Content-Security-Policy" content="${CSP}">`;
    const doctype = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i.exec(html);
    return doctype ? doctype[0] + meta + html.slice(doctype[0].length) : meta + html;
  };

  const frame = document.getElementById('app');
  const log = (text) => { document.getElementById('log').textContent += text + '\n'; };
  let port = null;

  window.addEventListener('message', (e) => {
    if (e.source !== frame.contentWindow || e.data?.type !== 'nxd:ready') return;
    const channel = new MessageChannel();
    port = channel.port1;
    port.onmessage = (ev) => log('from app: ' + JSON.stringify(ev.data));
    frame.contentWindow.postMessage({type: 'nxd:connect'}, '*', [channel.port2]);
    port.postMessage({type: 'nxd:init', doc: {text: 'Hallo'}, meta: {title: 'Test'}, readonly: false, hostCapabilities: {assist: false}});
  });

  fetch('app.html').then((r) => r.text()).then((html) => { frame.srcdoc = seal(html); });
</script>
</html>
```

<Steps>

1. Serve the folder with any static web server, for example `python3 -m http.server 8000`, and open `http://localhost:8000/host.html`. Opening the file directly from disk does not work, because the page loads `app.html` with `fetch`.

2. Edit something in the app. Each edit appears in the log as `from app: {"type":"nxd:change",...}`.

3. Play a co-editor or the AI Assistant from the browser console:

   ```js
   port.postMessage({type: 'nxd:apply', doc: {text: 'Von außen'}, origin: 'ai'})
   ```

4. Change `readonly: false` to `true` in `host.html`, reload, and check that every editing control is gone.

5. Open the browser's network panel. It must stay empty while the app runs: whatever the app tries to load is blocked here as it will be in nexxus.

</Steps>

Then try the app in nexxus. If you are not an administrator, your import becomes your own app, visible only to you and the administrators, which makes it a safe place to test. Check that a second person with a shared document sees your edits live, that a person with view rights gets a read-only app, and that the AI Assistant can build a document from your schema.

Your app is ready when it starts with the handshake, sends `nxd:change` on every edit, survives an `nxd:apply` in the middle of an edit, hides its editing controls when read-only, and loads nothing from the network.

## Publish

<Steps>

1. Raise `version` in `manifest.json` by one for every package you hand out. nexxus refuses a package whose version is not higher than the installed one, and the number is how you tell which build a tenant runs.

2. Add a `changelog` entry for the new version, written for the people who use the app.

3. Build the `.nxa`: `zip -j my-app.nxa manifest.json app.html`.

4. First install: <Ui>Import app</Ui> on <MenuPath to="Start > Apps" />. Every later release: <Ui>Upload new version</Ui> on the app's page. nexxus checks the package and shows <Ui>What changes</Ui> before anyone confirms. See [Create apps without code](/apps/create/#publish-a-new-version).

</Steps>

Releases keep every existing document. Keep new schema properties optional, and if you must change the meaning of existing data, make your app read the old shape too.

### Upload by API

For automated releases, the same steps are available as HTTP endpoints for a signed-in user. A package that fails the check returns status 400 with the reason as message.

| Method and path | What it does |
|---|---|
| `POST /api/dms-app/import` | Installs a package (multipart field `file`). |
| `POST /api/dms-app/{appId}/version/preview` | Checks a package as the next version and returns `valid`, `error`, `installedVersion`, `packageVersion` and the `changelog` entries it brings. Stores nothing. |
| `POST /api/dms-app/{appId}/version` | Publishes a package as the next version (multipart field `file`). |
| `GET /api/dms-app/` | Lists the apps the user can see, without their HTML. |
| `GET /api/dms-app/{appId}` | One app with its HTML, schema and effective AI instructions. |
| `GET /api/dms-app/{appId}/detail` | The app with document count, version history and settings. |
| `PATCH /api/dms-app/{appId}/settings` | Sets `aiInstructionsAddendum` (string, at most 8000 characters, or `null`) and `assistantDisabled` (boolean). |
| `DELETE /api/dms-app/{appId}` | Removes the app. Its documents stay. |

Who may do what is the same as in the interface: anyone signed in may import, administrators install for the tenant, and only the app's owner and administrators may publish versions, change settings or remove an app.

## Limits

| What | Limit |
|---|---|
| `app.html` | 2,000,000 bytes |
| `contributes` | 256,000 bytes, 8 menus, 24 items per menu |
| `aiInstructions`, addendum | 8000 characters each |
| App ID | `[a-z][a-z0-9-]{1,39}`, not `board` or `markdown` |
| One live co-editing message | 4 MB |
| Data the AI Assistant sees in full | 100,000 characters, above that the AI Assistant changes by JSON Patch |
| Schema sent to the AI Assistant directly | 20,000 characters, above that the AI Assistant fetches it |
| Export | 50 MB, 1 per second, 20 per minute, only after a click in the app |

## Next steps

<CardGrid>
  <LinkCard title="Create apps without code" description="Import, publish versions and set up an app in the interface." href="/apps/create/" />
  <LinkCard title="NXM modules" description="When a document is not enough: business modules with their own records and roles." href="/modules/overview/" />
</CardGrid>