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), 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.
- 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 writeroles;visible_whenoffers the page’s parameters; an action is picked or created in place (run a flowapp:{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
{
"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 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 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
"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. 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) |
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) and visible_when
(a condition). 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:
{ "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 (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.
{ "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.