An **app** is a JSON document that the dashboard renders as a web app at `/apps/{slug}`. It reads the
data the connector already has (printers, live machines, reconciliation reports, lookup tables, the event
log, dead letters), keeps its own small record sets (**collections**), and calls **flows** when a user
clicks a button or submits a form. Publishing swaps the definition in atomically; there is nothing to build,
deploy or restart.

| You want | Use |
|----------|-----|
| A page of live status for the shop floor | `live_grid`, `printer_board`, `stat`, `chart` |
| A form that stores records | `form` + a `collection` + a `collection_create` action |
| An approval inbox | `approvals` over a collection, a `sequence` action that records the decision and calls a flow |
| A button that runs a business process | `button` + a `flow` action (`app:{app}/{flow}` source node) |
| Maintain a code list | `form` or `table` row action + `lookup_upsert` (editors only) |
| A dashboard, a landing page, a knowledge page | a grid page of `stat`, `chart` (line, bar, area, pie, donut), `text` (markdown), `image`, `link`, `columns`, `tabs` |
| Several pages that link to each other | `navigation`, page parameters in the URL, `link` / `button` / row actions that open a page |

Three example apps are seeded on first start when absent (**Line board**, **Finance exceptions** and **Team hub**,
a general app that needs no flow: a dashboard with charts over a collection, a board and list switched by a page
parameter, a detail page opened from a row, a form, and an About page with columns, tabs, markdown and links).
They are ordinary apps: edit, publish or delete them (a deleted example is not brought back).

## Open, edit, publish

* Users open `/apps` (the launcher lists the apps their role may open) or `/apps/{slug}`. Sign-in is the
  dashboard's: SSO session or API token.
* Editors choose **Apps** in the user menu (or Ctrl/Cmd+K). The dialog has two views of the same definition,
  **Design** and **JSON** (see [The visual designer](#the-visual-designer)), a live preview of the **draft**,
  **Save draft**, **Publish…** (shows what changes first), **Revert** and **Unpublish**.
* Saving validates on the server. A bad component type, a reference to a source or action that does not
  exist, a role that is not a role, a template that reads something unavailable: refused with the JSON path
  and the fix, nothing is stored. *Warnings* (for example "no deployed flow has a source node
  `app:finance-exceptions/decide`") do not block.
* **Publish** validates again and copies the draft over the published definition in one atomic step. Users see
  it on their next request. Every save, publish, delete and every action a user runs is in the
  [audit log](/docs/security).
* Apps are stored in `<data_dir>/apps.json` (drafts and published copies) and collection records in
  `<data_dir>/apps.db`. App definitions are part of version snapshots, promotion bundles and Git export.

## The visual designer

**Design** edits the same JSON document the **JSON** view edits: it adds nothing of its own, so what it writes is
exactly what you would write by hand (keys in reading order, only the fields you set, unknown fields kept). Switch
views at any time: an edit in one shows in the other, and undo covers both. The draft saves by itself once the
server accepts it.

* **Canvas.** The real app, drawn by the real runtime with real data from the draft's sources, in a desktop, tablet
  or phone frame, in auto, light or dark. *Edit* mode selects and arranges; *Interact* mode uses the app (filters,
  row clicks, forms and buttons **run for real** against the draft).
* **Add.** Every component type. Drag one onto the page, or click it to add after the selection (inside a selected
  section). Drop between components of a stack, between the cells of a grid row, or into a section, a tab or a column. In a
  grid, drag the handle on the right edge to change the columns a component spans; the 12 columns show while you drag.
  The inspector's Layout group sets the start column, the tablet and phone spans and the rows a component takes; the
  page layout sets the tablet and phone columns, and the device frame of the canvas shows how it reflows.
* **Select and edit.** Click, Shift/Ctrl-click for several. Copy, cut and paste (also between apps: the clipboard holds
  JSON), duplicate, delete, group into a section, move earlier or later, undo and redo. Press `?` for the list.
* **Inspector.** Generated from the component schema (the field tables of the server): sources, actions, columns,
  filters, row actions, forms, tabs, columns, buttons, layout. `${…}` fields have a binding picker that browses what the field can read,
  with real values from the source's answer. Role pickers write `roles`; `visible_when` offers the page's parameters;
  an action is picked or created in place (run a flow `app:{app}/{flow}`, create, update or delete a record, a lookup
  entry, a sequence). A form's fields come from an inline schema you edit field by field, a collection, an event type,
  or a pasted JSON Schema.
* **Layers, Pages, App.** The component tree of the page; pages (add, duplicate, reorder, delete) with navigation
  (style, home, custom items that open a page with parameters or an address, each with an optional condition); the app's
  name, icon, address and roles, and the theme (colours with a light and dark swatch, logo and dark-mode logo, density,
  appearance, corner radius, typeface, app bar colour).
* **Data.** Sources (with a **Read the source** test), actions and collections (with their JSON Schema). Renaming
  rewrites every reference; deleting lists what is left pointing at nothing.
* **Problems on the canvas.** The server's validation paths are mapped onto the component they name: it is outlined,
  flagged with the count, and the inspector shows the message under the field.
* **Publish.** *Publish…* opens the draft against the published definition: a list of added, removed, changed and
  moved pages, components, sources, actions and collections, and the exact JSON diff. Errors block it; warnings do not.

## The definition

```json
{
  "id": "finance-exceptions",
  "slug": "finance",
  "title": "Finance exceptions",
  "theme": { "primary": "#1d4ed8", "density": "comfortable", "mode": "auto", "radius": "md" },
  "roles": ["viewer"],
  "collections": { "requests": { "write_roles": ["operator"], "schema": { "type": "object", "properties": { "title": { "type": "string" } } } } },
  "sources":     { "pending": { "type": "collection", "collection": "requests", "filter": { "status": "pending" } } },
  "actions":     { "raise": { "type": "collection_create", "collection": "requests" } },
  "pages": [ { "id": "home", "title": "Home", "components": [ { "id": "t", "type": "table", "source": "pending", "columns": [{ "field": "title" }] } ] } ]
}
```

| Field | Meaning |
|-------|---------|
| `id` | `[a-z0-9][a-z0-9-]{0,47}`; fixed for the life of the app; part of the flow source `app:{id}/{flow}` |
| `slug` | URL `/apps/{slug}` (default `id`); unique |
| `title`, `description`, `icon` | Shown in the launcher and the shell (`icon` = a [lucide](https://lucide.dev) name) |
| `theme` | `primary`, `accent` (`#rrggbb`), `logo` and `logo_dark` (https URL, `/path` or small `data:image`; the dark one is used in dark mode), `density` `comfortable`\|`compact`, `mode` `auto`\|`light`\|`dark`, `radius` `none`\|`sm`\|`md`\|`lg`, `font` `sans`\|`serif`\|`mono`, `header` `default`\|`primary` (the app bar in the primary colour) |
| `roles` | Minimum roles that may open the app (default `viewer`) |
| `navigation` | `style` `sidebar`\|`top`, `items` `[{label, page \| href, params?, icon?, roles?, visible_when?}]`, `home` (default: every non-hidden page in order). A `page` item may carry `params` (page parameters of the target; the item is current when they match) and an `href` item opens an address; `visible_when` is a [condition](#conditions) over `params`, `user`, `app`, `now` |
| `collections` | App-owned record sets, see below |
| `sources` | Named data sources, see below |
| `actions` | Named actions, see below |
| `pages` | Up to 50 pages; each `{id, path?, title, icon?, roles?, hidden?, layout?, params?, components[]}` |

### Roles

The platform roles are `viewer < operator < editor < admin`. In an app, `roles: ["operator"]` is a **minimum**
role: operators, editors and admins pass. With several entries the lowest one counts. The requirement of a
component is the strictest of the app, the page and the component itself; sources and actions have their own.
The **server** enforces all of it: the runtime payload already omits pages, components, buttons and row
actions the caller may not use, and a data source or action call from a role that does not reach it is
refused with 403 `{required_role}` whatever the browser sends. Defaults: reads follow the app; `flow` sources and
`dead_letters` need `operator`; `collection_*` and `flow` actions need `operator` (a collection's `write_roles`
is a floor they cannot lower); `lookup_*` actions need `editor`; a `sequence` needs the strictest of its steps.

### Collections

```json
"collections": {
  "requests": {
"title": "Approval requests",
"read_roles": ["viewer"],
"write_roles": ["operator"],
"max_records": 50000,
"schema": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "minLength": 3 } } }
  }
}
```

Records are JSON documents validated against a JSON Schema on every write: `schema` inline, or
`schema_from_event` to reuse a type of the [event catalogue](/docs/business-events). The API adds system fields
`_id`, `_rev`, `_created_at`, `_updated_at`, `_created_by`, `_updated_by`. Reads are paged, sorted and filtered in
SQL. An update that sends `_rev` is refused with 409 when someone else changed the record first.

### Data sources

Each source has an optional `roles` and is read with `POST /api/apps/{slug}/data/{source}`; the answer is always
`{rows, total, record, page, page_size, meta}`.

| `type` | Fields | `rows` / `record` |
|--------|--------|-------------------|
| `collection` | `collection`, `filter` (exact match, templates), `order` `{field, dir}` | records; `record = {count}` |
| `flow` | `flow` (name), `params` (templates, default: the whole `input`), `wait_ms` | `record` = the flow's **final record**; `rows` = `record.rows` or `record.items` |
| `lookup` | `table` | entries `{key, ...values, updated_at}` |
| `events` | `event_type`, `subject`, `source`, `limit` (max 500) | event envelopes (privacy-masked) |
| `reconcile` | `view` `reports`\|`report`\|`ledger`, `name`, `day`, `ok`, `id` | reports / one report's exceptions / ledger tallies |
| `dead_letters` | `status` | dead letters (masked); `operator` minimum |
| `cases` | `view` `list`\|`summary`\|`case`, `id`, `status`, `ledger`, `kind`, `day`, `from`, `to`, `assignee`, `sort` | reconciliation exception cases / the ageing summary / one case's history (see [Finance controls](/docs/finance-controls#in-apps)) |
| `approvals` | `status`, `node`, `mine` | records parked by `approve` nodes with `can_decide`; feeds the `approvals` component |
| `posting` | (none) | every http / soap node with its posting mode and go-live sign-off |
| `printers` | `view` `status`\|`jobs`, `printer`, `line` | printers with connection and counters / the job log |
| `live` | `machines` | latest known record per machine; the browser then follows `WS /api/ws/live` |

### Actions

An action is run with `POST /api/apps/{slug}/actions/{action}` and `{input}`; each call is audited as `app.action`
with the app, action, collection and record id (never the field values).

| `type` | Fields |
|--------|--------|
| `flow` | `flow` (default: the action name), `params`, `wait_ms` |
| `collection_create` | `collection`, `data` (templates laid over the input fields the schema names) |
| `collection_update` | `collection`, `id` (default `${input.id}`), `data`: merged onto the stored record |
| `collection_delete` | `collection`, `id` |
| `lookup_upsert` / `lookup_delete` | `table`, `key` (default `${input.key}`), `values` |
| `case` | `op` `assign`\|`comment`\|`status`\|`link`\|`bulk`, `id` (default `${input.id}`), `ids`, `assignee`, `text`, `status`, `resolution`, `comment`, `reference` |
| `approval` | `decision` `approve`\|`reject`, `id` (default `${input.id}`), `comment`; the approve node's own role / users still decide who may |
| `sequence` | `steps`: action names run in order with the same input; stops at the first failure |

All have `roles`, `label` and `success` (the toast text, a template).

### Templates

`${path}`: a dotted path with optional `[n]` indexes, nothing else (no operators, no calls, no code). A string
that is exactly one expression keeps the value's type; otherwise it is text interpolation (missing = empty).
`${path|fallback}` supplies a default.

* In components (resolved in the browser): `params` (page parameters, kept in the URL), `row`, `form`, `user`, `app`, `now`, `data`.
* In sources and actions (resolved on the server): `input` (what the browser sent), `user` (trusted: from the session or token), `app`, `now`.

### Components

Every component has `id` (unique in the app), `type`, and optionally `title`, `subtitle`, `roles`, the grid placement
fields (`span`, `start`, `span_md`, `span_sm`, `row_span`, see [Layout](#layout-and-breakpoints)) and `visible_when`
(a [condition](#conditions)). Unknown fields are rejected.

| `type` | Purpose | Main fields |
|--------|---------|-------------|
| `text` | Markdown: `#` headings, lists, quotes, rules, tables, code, bold, italic, links, images, links to a page | `markdown` |
| `image` | A picture, optionally a link | `src`, `alt`, `fit`, `height`, `page` + `params` or `href` |
| `link` | A link to a page of the app or an address, as text or a card | `label`, `page` + `params` or `href`, `description`, `icon`, `variant` `link`\|`card` |
| `button_group` | A row of buttons | `buttons[]` (each a `button`: `action`, `page` or `href`; plus `visible_when`), `align` |
| `stat` | One number | `source`, `value` (path in the record), `format`, `delta`, `tone`, `refresh_s` |
| `chart` | `line`, `bar`, `area` (time axis, stacking) and `pie`, `donut` | `source`, `kind`, `x`, `y[]`, `agg`, `time`, `bucket`, `stacked`, `series_by`, `limit` |
| `table` | Server-side paging, sort, search, filters, CSV export, row actions | `source`, `columns[]`, `page_size`, `search`, `export_csv`, `filters[]`, `row_actions[]`, `select_param` |
| `detail` | Key-value view of one record | `source`, `fields[]`, `actions[]` |
| `form` | Fields generated from a JSON Schema | `action`, `schema` or `schema_from`, `fields[]` overrides, `submit_label`, `reset_on_success` |
| `button` | Runs an action, or opens a page or an address | `button: {label, action \| page \| href, params, input, confirm, variant}` |
| `approvals` | Inbox with approve / reject and a comment | `source`, `title_field`, `approve`, `reject`, `comment` |
| `live_grid`, `live_tile` | Machine tiles over WebSocket | `source`, `tile`, `machine` |
| `printer_board` | Printer cards | `source`, `jobs_source` |
| `section` | A group, optionally a card, collapsible, with its own layout | `components[]`, `layout`, `variant` `plain`\|`card`\|`muted`, `tone`, `icon`, `collapsible` |
| `columns` | Side-by-side columns (1-6) that stack on a phone | `columns[]` `{id, title?, span?, visible_when?, components[]}`, `gap` |
| `tabs` | Tabbed group | `tabs[]` `{id, title, icon?, visible_when?, components[]}` |
| `filters` | Controls bound to page parameters | `fields[]` |

Fields appear in the order of `fields[]`, then the schema's `x-order` (a list of property names), then required fields first. Forms support flat schemas: `string` (with `format` date, date-time, email, uri; `enum` becomes a styled select),
`number`, `integer`, `boolean`, and arrays of enum strings. Column `format` is one of `text`, `number`, `integer`,
`percent`, `currency`, `datetime`, `date`, `relative`, `duration`, `bytes`, `badge`, `boolean`, `json`, `code`.
A `currency` column, field or stat takes the code from the row (`currency_path`, a dotted path such as `currency`) and otherwise from the fixed `currency`; with neither it shows the plain number, never a default currency.

### Layout and breakpoints

A page (and a `section`) has a `layout`: `stack` (one under the other), `grid` or `tabs` (each top-level component
becomes a tab). A grid is `columns` wide (1-12, default 12) with a `gap` of `sm`, `md` or `lg`, and reflows with the
width of the page, not the window: below 880 px it has `columns_md` columns (default 2) and below 560 px `columns_sm`
(default 1). A component takes its place with:

| Field | Meaning |
|-------|---------|
| `span` | Columns on the desktop grid (default: the whole row) |
| `start` | First column (1-12); with no `span` it runs to the end of the row |
| `span_md`, `span_sm` | Columns on the tablet and phone grid (default: the same share of the smaller grid, rounded up) |
| `row_span` | Rows to span (1-8); a tall card beside two short rows |

The server refuses a `span`, `start`, `span_md` or `span_sm` outside 1-12, a `start` + `span` that runs past the last
column, a `span_md` wider than the tablet grid and a `span_sm` wider than the phone grid; `columns_md` may not exceed
`columns` nor `columns_sm` exceed `columns_md`. Placement fields in a container that is not a grid are ignored and
reported as warnings. `columns` (the component) lays out 1-6 columns on the 12; a column's `span` is its width and the
ones without share what is left (the spans add up to at most 12). Even columns pair up on a tablet, uneven ones stack.

### Links, buttons and navigation

A **link target** is `page` (a page id or path of this app, with `params` that become the page's URL parameters) or
`href` (`https://`, `http://`, `mailto:`, `tel:` or a `/path`; a `${…}` template is allowed). Give exactly one. The
`link`, `image`, `button`, `button_group` buttons, `row_actions[]`, `detail` `actions[]` and `navigation.items[]` all take
one; a button, row action or detail action takes `action` instead. A `page` that does not exist is refused when you
save, and the runtime drops a link, button or menu item to a page the viewer's role may not open (an image keeps itself
and loses its link). Absolute addresses open in a new tab; nothing else than the schemes above ever becomes a link, so
`javascript:` in a template cannot run. In markdown, `[text](page:orders?status=open)` links to a page.

Page parameters live in the URL (`/apps/{slug}/orders?status=open`), are declared per page with `params` (type and
default) and read as `${params.status}`; a `table` can set one with `select_param`, a `filters` component and an
`on_success.set_params` too. A menu item with `params` is the current one when they match, so *Board* and *List* can be
two items for one page.

### Charts

`chart` reads the rows of any source, including a `collection`. `kind` is `line`, `bar`, `area`, `pie` or `donut`.
Rows that share an `x` (and series) are combined with `agg`: `sum` (default), `avg`, `min`, `max` or `count` (rows per
`x`, no `y` needed). `time: true` makes `x` a date axis; `bucket` (`hour`, `day`, `week` from Monday, `month`) groups
it first, so "records per day" is `x: _created_at, time: true, bucket: day, agg: count`. `series_by` splits one `y` by
a field. A pie or donut draws one slice per `x` label valued by its single `y` (or the count), biggest first; zero and
negative values are left out and `limit` (2-50) keeps the largest slices and groups the rest as **Other**. The charts
are plain SVG with a legend, hover and keyboard reading, and a hidden table for screen readers.

### Conditions

`visible_when` (components, tabs, columns, menu items, buttons in a group) and `when` (row and detail actions) take a
plain template, true when it renders to something that is not empty, `false`, `0` or `null`, or a condition:
`!x`, `a == b`, `a != b` (compared as text; a missing value is empty; quotes are optional), joined with `&&`
(binds tighter) and `||`, for example `${user.role} == admin || ${params.view} != list`. There are no other operators and
no parentheses. The server refuses a condition with an empty side. A condition is a convenience for the interface;
what a role may read or run is enforced by the server on every request.

### Theme

`theme` sets the `primary` and `accent` colours (the runtime moves a colour toward the text colour until it reads
at 4.5:1 in light and in dark), the `logo` and a `logo_dark` for dark mode, the `font` (`sans`, `serif`, `mono`),
`radius`, `density`, `mode` (`auto` follows the person; people can still switch light, dark or auto in the app bar) and
`header: primary` to paint the app bar in the primary colour.

## Calling a flow

A `flow` source or action runs the flow source node whose `match` is `app:{app id}/{flow}` (exactly that shape;
no wildcards; a malformed one fails the flow deploy). The record handed to the flow is the resolved `params`
(or the whole input) plus a server-set `_app` object that the caller cannot forge:

```json
{ "request_id": "…", "status": "approved", "_app": { "id": "finance-exceptions", "action": "notify", "flow": "decide",
  "user": { "name": "dewi", "email": "dewi@example.com", "role": "editor" }, "at": "2026-10-30T08:12:44Z" } }
```

The call waits like [`/api/ingest`](/docs/rest-api) (default 15 s, `wait_ms` up to 60 s). When the flow finishes,
the app receives **the last record the flow emitted** (`record`; here the business-event envelope made by the `event` node): 200 `done`, 422 `failed` with the flow's reason,
503 `retry`, 202 `pending` when still running. No deployed source for the key is a 404 that names the key.

```json
{ "id": "decide", "type": "source", "match": "app:finance-exceptions/decide" }
{ "id": "wrap",   "type": "event", "event_type": "payment.confirmed" }
```

## REST API

| Method | Path | Role |
|--------|------|------|
| GET | `/api/apps` | viewer (lists what the caller may open; editors also see drafts) |
| POST | `/api/apps/validate` | editor |
| GET, PUT, DELETE | `/api/apps/{id}` | editor (`PUT ?if_rev=`, `DELETE ?purge=true`) |
| POST | `/api/apps/{id}/publish`, `/unpublish`, `/revert` | editor |
| GET | `/api/apps/{slug}/runtime[?draft=true]` | viewer (trimmed to the caller's role; `draft` = editor) |
| POST | `/api/apps/{slug}/data/{source}` | viewer + the source's roles |
| POST | `/api/apps/{slug}/actions/{action}` | viewer + the action's roles |

`PUT` answers 422 `{errors:[{path, message}], warnings}` for an invalid definition, 409 for a stale `if_rev` or a
slug already used. MCP tools: `list_apps`, `get_app`, `upsert_app` (validates), `publish_app`.

## Limits

* No arbitrary JavaScript: bindings are `${path}` templates only; conditions have `!`, `==`, `!=`, `&&` and `||` and nothing else; computation belongs in a flow.
* There are no public (signed-out) apps. Every route of the connector, including the data and action routes of an app,
  needs a signed-in user or an API token, and an app is shown only to a role that may open it. Serving an app to
  anonymous visitors would need a separate, rate-limited principal that can read only explicitly published sources;
  the connector has no such principal, so the option is not offered.
* Forms are flat (no nested objects); the form schema of a business event drops properties it cannot render and
  warns.
* A definition is at most 512 KiB, 50 pages, 400 components; a record 256 KiB; a collection 50 000 records by default.
* Dead letters, events and the printer job log are read-only data sources; apps do not edit flows.
* The designer edits one app at a time and never edits flows: it shows the `app:{app}/{flow}` key a flow needs and
  whether a deployed flow has it.