kind: View — see
A declarative, read-only list over a scope's table. The hub renders it;
lm, the TUI or the PWA display it. One YAML says which columns. A filter it cannot apply is refused instead of showing an empty table.
| Kind | View — apps.lumnik.io/v1 |
| Over | any table of a scope's card: a raw connector.t_*, or a fused connector.v_* |
| Renders | lm view -f FILE or lm view NAME (list, filters, one record's detail); the TUI's :view; the PWA's Records tab |
| Store | lm view apply / lm view rm — lm_integrator, scope-bound; reads lm_user |
| Bounds | 1 000 rows and a 5-second statement timeout per read, read-only, tenant-isolated; when more rows match, lm view ends with first 1000 rows shown; more match — narrow with --filter (-o json: "truncated": true) |
| Edition | Open |
A view is a title, a table and the columns worth looking at. Over an entity it uses the entity's labels, and a record's detail ends with the sources' disagreements on it. The file is the app — hand-written, or drafted by the AI.
Features
- Over a raw table or a fused entity. A freshly landed export is
viewable as it is; an entity's view carries its labels and its
_conflictsledger. - A filter grammar that refuses. An unknown column, a column the view does not show, a malformed filter or a value outside a declared domain is refused.
- One record's detail, disagreements included.
lm view KEYprints a record and, under Ambiguïtés et divergences, which source said what. - A store the PWA discovers.
lm view applypublishes a manifest; anyone holding the scope finds it on their phone. - Drafted by the AI, gated like a render. The model picks table, columns and filters; the hub validates them and writes the YAML.
Quickstart
Every screen on this page reads the demo desk that ships with the repo
Land the three sources as The demo desk shows, then
apply the customer entity:
lm entity apply -f docs/apps/demo/entities/10-customer.yaml.
# 10-customers.yaml — the desk's customer list, over the fused entity.
#
# Column headers are the labels the entity manifest declares; the detail of one record ends
# with its _conflicts ledger when the sources conflict on it.
#
# lm view -f docs/apps/demo/views/10-customers.yaml
# lm view -f docs/apps/demo/views/10-customers.yaml --filter city=Lyon
# lm view P-1000 -f docs/apps/demo/views/10-customers.yaml
kind: View
apiVersion: apps.lumnik.io/v1
metadata:
name: acme-customers
spec:
scope: desk
table: connector.v_acmecustomer
title: Clients ACME
columns: [code, name, city, segment, credit_limit]
$ lm view -f docs/apps/demo/views/10-customers.yaml
Clients ACME
Code client Raison sociale Ville Segment Encours autorisé
P-1000 Hydraulique Lyon 1000 Lyon GOLD 500
P-1001 Roulements Bruxelles 1001 Bruxelles SILVER 637
P-1002 Fixations Milano 1002 Milano BRONZE 774
P-1003 Pneumatique Porto 1003 Porto GOLD 911
P-1004 Étanchéité Kraków 1004 Kraków SILVER 1048
P-1005 Transmission Aarhus 1005 Aarhus GOLD 1185
P-1006 Outillage Valencia 1006 Valencia BRONZE 1322
P-1007 Levage Bristol 1007 Bristol SILVER 1459
P-1008 Filtration Lyon 1008 Lyon GOLD 1596
P-1009 Soudure Bruxelles 1009 Bruxelles BRONZE 1733
P-1010 Hydraulique Milano 1010 Milano SILVER 1870
P-1011 Roulements Porto 1011 Porto 2007
P-1012 Levage Aarhus 1012 Aarhus
P-1013 Filtration Valencia 1013 Valencia
The headers are the entity's labels. Fourteen rows, ordered by the first declared column.
Narrow it — filters are repeatable and ANDed, and the title lists them:
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter segment=GOLD --filter 'credit_limit>1000'
Clients ACME · segment=GOLD · credit_limit>1000
Code client Raison sociale Ville Segment Encours autorisé
P-1005 Transmission Aarhus 1005 Aarhus GOLD 1185
P-1008 Filtration Lyon 1008 Lyon GOLD 1596
One record — the first declared column is the key:
$ lm view P-1008 -f docs/apps/demo/views/10-customers.yaml
Clients ACME / P-1008
Code client P-1008
Raison sociale Filtration Lyon 1008
Ville Lyon
Segment GOLD
Encours autorisé 1596
(!) Ambiguïtés et divergences
Ville t_acme_desk_erp «Lyon» · t_acme_desk_crm «Villeurbanne»
Ambiguïtés et divergences is the entity's _conflicts ledger for that record: the ERP (listed
first) won with Lyon, and the CRM's Villeurbanne is kept. A record both sources agree on:
$ lm view P-1005 -f docs/apps/demo/views/10-customers.yaml
Clients ACME / P-1005
Code client P-1005
Raison sociale Transmission Aarhus 1005
Ville Aarhus
Segment GOLD
Encours autorisé 1185
✓ aucune ambiguïté ni divergence
The manifest
| Field | Required | Meaning |
|---|---|---|
metadata.name |
yes | the view's name — in the store, lower-cased, a URL-safe slug ([a-z0-9_-], starting with a letter or digit) |
spec.scope |
yes | the scope whose schema card backs the view |
spec.table |
no | defaults to the scope's single table; required when the scope has several. Raw connector.t_* tables and fused connector.v_* views both qualify |
spec.title |
no | the display title — falls back to the entity's label:, then the table's name |
spec.columns[] |
yes (≥ 1) | the columns to show, in order. Every one must exist in the scope's card. The first is the sort order and the detail key |
spec.links[] |
no | cells that lead somewhere: another view, or an external page — see Linking views |
apply refuses a key it does not know:
$ lm view apply -f docs/apps/demo/views/61-refused-unknown-key.yaml
Error: API error 400: unknown key(s) in the manifest: spec.titel — kind:View honors root {apiVersion, kind, metadata, spec}, metadata {name}, spec {columns, links, scope, table, title}, spec.links[] {column, filters, targetView, url}
lm view -f still renders such a file: the unknown key is ignored, so this one shows under the
table's name instead of a title.
Linking views
A cell can lead somewhere: to another view, or to a page of your ERP. Declare it on the view the user starts from:
# 40-customers-links.yaml — the desk's customers, with two cells that lead somewhere.
#
# Tap a customer's name: the orders view opens, already filtered on that customer.
# Tap a customer's code: the customer's page in the ERP opens in a new tab.
apiVersion: apps.lumnik.io/v1
kind: View
metadata:
name: acme-customers-linked
spec:
scope: desk
table: connector.v_acmecustomer
title: Clients ACME — liens
columns: [code, name, city, segment]
links:
- column: name
targetView: acme-orders
filters:
code: "{code}"
- column: code
url: "https://erp.example.com/customers/{code}"
- Tap a name: the
acme-ordersview opens, already filtered on that customer. The filter shows in the filter bar, and removes like one the user typed. - Tap a code:
https://erp.example.com/customers/P-1008opens in a new tab. {code}is the value of that column on the row tapped. It must be one of the view'scolumns.
What the tap on Filtration Lyon 1008 opens is this list:
$ lm view acme-orders --filter code=P-1008
Commandes · code=P-1008
order_no code status amount ordered_on
SO-000006 P-1008 EN LITIGE 4100.00 2026-08-07
Apply the target first, or later. A link to a view that does not exist yet is kept, with a warning. It works once the target is applied:
$ lm view apply -f docs/apps/demo/views/40-customers-links.yaml
applied: acme-customers-linked (scope desk)
! 40-customers-links.yaml — 0 error(s), 1 warning(s)
WARN spec.links[0].targetView
targetView 'acme-orders' is not a view of scope 'desk' (yet); the declaration is kept, and following the link is refused until that view is applied
$ lm view apply -f docs/apps/demo/views/20-orders.yaml
applied: acme-orders (scope desk)
- One destination per link:
targetViewwith at least one filter, orurl. - The target must be a view of the same scope. If it refuses the filter, the user stays where they are and reads why.
- An external link is
https://only. The other system receives the value: link only to systems you trust with it. - Limits: one link per column, 16 links, 8 filters per link.
The same rules apply to a search result's link to your ERP.
A view over a raw table
No entity is needed: a view can point straight at a raw table the scope's connectors land — here the order book, as the ERP exported it:
# 20-orders.yaml — a view straight over a raw ingest table, no entity needed.
#
# A scope's card lists the raw tables as well as the fused views, so a freshly landed export
# is viewable as it is. The status column is shown exactly as the ERP spells it.
#
# lm view -f docs/apps/demo/views/20-orders.yaml --filter 'status~litige'
# lm view -f docs/apps/demo/views/20-orders.yaml --filter 'amount>1000'
kind: View
apiVersion: apps.lumnik.io/v1
metadata:
name: acme-orders
spec:
scope: desk
table: connector.t_acme_desk_orders
title: Commandes
columns: [order_no, code, status, amount, ordered_on]
$ lm view -f docs/apps/demo/views/20-orders.yaml --filter 'status~litige'
Commandes · status~litige
order_no code status amount ordered_on
SO-000006 P-1008 EN LITIGE 4100.00 2026-08-07
SO-000010 P-1004 EN LITIGE 77.00 2026-08-12
The headers are the raw column names, because nothing declared a label. A
kind: Workflow is what declares what the status values mean.
Rendering
lm view -f FILE # the list, from a manifest file
lm view -f FILE --filter city=Lyon # filtered; repeatable, ANDed
lm view KEY -f FILE # one record — KEY matches the first declared column
lm view NAME [KEY] # the same, from a view saved in the store
A saved view renders without a file:
$ lm view acme-customers --filter segment=GOLD
Clients ACME · segment=GOLD
Code client Raison sociale Ville Segment Encours autorisé
P-1000 Hydraulique Lyon 1000 Lyon GOLD 500
P-1003 Pneumatique Porto 1003 Porto GOLD 911
P-1005 Transmission Aarhus 1005 Aarhus GOLD 1185
P-1008 Filtration Lyon 1008 Lyon GOLD 1596
Rendering needs lm_user and stays inside the business-scope boundary: a view
whose spec.scope you do not hold answers Error: access refused (403): scope not granted.
- Rows are sorted by the first declared column.
lm viewshows at most 1 000 rows. Past that it saysfirst 1000 rows shown; more match — narrow with --filter.- A render that takes more than 5 seconds is refused, and says so. Narrow it with filters.
- The PWA reads a view 50 rows at a time.
In the TUI, :view opens the same file interactively:
| Key | Action |
|---|---|
f |
add a filter |
F |
clear the filters |
r |
re-render |
Enter |
one record's detail |
o |
open another manifest |
g |
generate a manifest from a sentence |
a |
apply the rendered view to the store |
Filter grammar
| Form | Meaning |
|---|---|
col=value |
exact match; a date against a date or timestamp column is equal as an instant (due=2026-07-01 is midnight UTC); a number against a number column by value (amount=1500.00 finds 1500); on a column with declared values, an out-of-domain value is refused |
col~text |
substring, case-insensitive |
col>v col<v col>=v col<=v |
ordering — v must be a number or an ISO-8601 date; a row whose value has neither shape is not matched |
The ordering forms work on the text columns a CSV or a fusion produces — credit_limit>1000
above, and on the order book:
$ lm view -f docs/apps/demo/views/20-orders.yaml --filter 'ordered_on>2026-08-10'
Commandes · ordered_on>2026-08-10
order_no code status amount ordered_on
SO-000009 P-1010 120.00 2026-08-11
SO-000010 P-1004 EN LITIGE 77.00 2026-08-12
SO-000011 P-1006 BROUILLON 3000.00 2026-08-12
SO-000012 P-1011 En cours 55.00 2026-08-13
Filter details
- Ordering works on numbers and dates, and on text that holds them.
money,time,interval,booleananduuidcolumns refuse it, naming the column: use=or~. - A date without an offset is read as UTC:
ordered_on>2026-08-10starts at midnight UTC. A process reads a date the same way.
Refusal rules
Each refusal names the fix:
$ lm view -f docs/apps/demo/views/60-refused-unknown-column.yaml
Error: API error 400: column 'phone' is not in the 'desk' schema for table connector.v_acmecustomer — fix the column name or add it to the scope's entity
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter email~example
Error: API error 400: cannot filter on 'email' — the view does not show it; declare it in spec.columns to filter on it
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter 'city~'
Error: API error 400: filter 'city~' needs a column and a value — col=value, col~text, or col>value
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter segment=PLATINUM
Error: API error 400: 'PLATINUM' is not an allowed value for segment (allowed: GOLD, SILVER, BRONZE)
$ lm view -f docs/apps/demo/views/10-customers.yaml --filter "city='Lyon'"
Error: API error 400: filter value 'Lyon' is quoted; a value here is a bare literal, so the quotes would be compared to city too — write city=Lyon
A zero-row result is then meaningful: the filter is valid and matches nothing.
The saved-view store
A manifest can also live in the hub, where the PWA at /app/ finds it under its scope:
$ lm view apply -f docs/apps/demo/views/10-customers.yaml
applied: acme-customers (scope desk)
$ lm view apply -f docs/apps/demo/views/20-orders.yaml
applied: acme-orders (scope desk)
$ lm view list
NAME SCOPE TITLE
acme-customers desk Clients ACME
acme-orders desk Commandes
$ lm view get acme-customers -o acme.yaml
manifest written to acme.yaml — review it, then: lm view -f acme.yaml
lm view get NAME without -o prints the stored YAML, comments included, byte for byte.
| Command | Effect | Role |
|---|---|---|
lm view apply -f FILE |
validate-then-upsert: a view that cannot render is never saved; re-applying a name overwrites | lm_integrator |
lm view list [--scope TAG] |
NAME · SCOPE · TITLE — a scope-bound user sees only the granted scopes' views |
lm_user |
lm view get NAME [-o FILE] |
the manifest YAML verbatim | lm_user |
lm view rm NAME |
removes the view for good, no prompt | lm_integrator |
$ lm view rm acme-orders
view acme-orders deleted
$ lm view rm acme-orders
Error: API error 404: no saved view named 'acme-orders'
- There is no disable: a saved view is live from
applyuntilrm. applyandrmare scope-bound: a scope-bound integrator cannot publish or remove a view outside their grants.- Every apply and removal is recorded in the audit trail.
- A saved view is visible to every tenant of the hub. Each one sees only its own rows.
A user holding only scope:stock sees nothing of the desk:
$ lm view list
NAME SCOPE TITLE
$ lm view list --scope desk
Error: access refused (403): scope not granted
The stored name must be a URL-safe slug, and four names are reserved:
$ lm view apply -f docs/apps/demo/views/62-refused-reserved-name.yaml
Error: API error 400: metadata.name must be a URL-safe slug ([a-z0-9_-], starting with a letter or digit) and not one of [detail, render, apply, generate]
Generate a view with the AI
Describe the need; the hub's model selects the table, the columns and the filters, the hub validates them like a render, and writes the YAML. The manifest is yours to review and keep.
$ lm view generate desk "les clients de Lyon" -o lyon.yaml
manifest written to lyon.yaml — review it, then: lm view -f lyon.yaml
$ cat lyon.yaml
# asked for: les clients de Lyon
# kept: city=Lyon — a condition of that description not listed here is NOT enforced
# lm view -f lyon.yaml --filter 'city=Lyon'
apiVersion: "apps.lumnik.io/v1"
kind: "View"
metadata:
name: "clients_of_lyon"
spec:
scope: "desk"
table: "connector.v_acmecustomer"
title: "Clients of Lyon"
columns:
- "code"
- "name"
- "city"
- "segment"
- "credit_limit"
| Header line | Tells you |
|---|---|
# asked for: |
your sentence, on one line |
# kept: |
the filters the view enforces — anything else in your sentence is not enforced |
# lm view -f … |
a ready-to-run render of your -o file |
Within generate, -o is the output file, not the output format.
- Generation is rate-limited per tenant:
lumnik.ratelimit.view-generate.rpm, default 10 per minute; past it,lmprintsrate limited (429): rate limit exceeded, retry in Ns.
Generation contract
| Case | Answer |
|---|---|
| The scope cannot serve the description ("les commandes" over a scope with no orders) | the model abstains; you get its reason, never invented columns |
| No card for the scope | no schema for scope '…' — run discovery first |
| No chat model on the hub | generation abstained: no chat model is available on this hub — view generation needs one |
Nothing checks that the model kept everything you asked for — read the # kept: line:
$ lm view generate desk "les commandes en litige de plus de 500 euros" -o litiges.yaml
manifest written to litiges.yaml — review it, then: lm view -f litiges.yaml
$ head -3 litiges.yaml
# asked for: les commandes en litige de plus de 500 euros
# kept: amount>500 — a condition of that description not listed here is NOT enforced
# lm view -f litiges.yaml --filter 'amount>500'
Two conditions asked, one kept: en litige was dropped. Add the filter by hand, or rephrase.
"Run discovery first": lm source rediscover --scope <tag> rebuilds the
schema card (needs lm_integrator and lm_admin, and a
scope you hold). lm source schema --scope <tag> shows what the card holds.
How to…
Show one team a filtered list on their phone
lm view apply -f docs/apps/demo/views/10-customers.yaml
Result: anyone holding scope:desk finds Clients ACME under the Records tab of the PWA
at /app/. On the phone, a text column offers contient and =; ordering (>, <) needs a
column typed at the source (parse-number, parse-date).
Let the AI draft a view, then check it before anyone else sees it
lm view generate desk "les clients de Lyon" -o lyon.yaml
cat lyon.yaml # review it — the third line is the suggested render
lm view -f lyon.yaml --filter city=Lyon # test it yourself
lm view apply -f lyon.yaml # only once it looks right
Result: only apply writes to the store. Nobody else sees the draft before that.
Look at an export before any entity exists
Point spec.table at the raw connector.t_* table — 20-orders.yaml above. Result: the
export's own columns, unlabelled, filterable the same way.
Troubleshooting
| Symptom | Cause | Gesture |
|---|---|---|
apply refused: unknown key(s) in the manifest: … |
a misspelled field (titel:) |
the message lists what kind:View honors — fix the spelling |
render or apply refused: column is not in the '…' schema for table … |
spec.columns names a column the scope's card does not have |
fix the column name, or add it to the scope's entity |
render refused: column '…' is not in … — the entity changed since this view was written |
the entity lost that column, but the scope's card still lists it. In another tenant than the one that applied, this lasts until the card is rebuilt. If that rebuild failed (the hub log says so), it stays | drop the column from spec.columns, or put it back in the entity; lm source rediscover --scope <tag> refreshes the card |
filter refused: cannot filter on '…' — the view does not show it |
filtering on a column not listed in spec.columns |
add the column to spec.columns |
filter refused: '…' is not an allowed value for … |
the entity declares a values domain for that column |
one of the listed values — or fix the domain in the entity |
filter refused: '…' is not a number or ISO date — > needs an ordered value |
an ordering filter with a value that is neither | a number, or YYYY-MM-DD |
apply answers with a warning on spec.links[…].targetView |
the target view is not applied yet in that scope | apply the target; the link then works |
apply refused: spec.links[…].url: an external link is https://… |
http:, a relative address, or a host that is not written out |
write the full https://host/path/{column} |
following a link answers Lien refusé : … |
the target refused the filter | fix the filter in links[].filters, or the target's columns |
403 … scope not granted |
you do not hold the spec.scope role |
ask for that scope: role |
apply refused: metadata.name must be a URL-safe slug … |
the name is not a lowercase slug, or is apply/render/detail/generate |
rename metadata.name |
rate limited (429): rate limit exceeded, retry in Ns on generate |
over 10 lm view generate calls per minute for the tenant |
wait the seconds it names, then retry |
404 … no schema for scope '…' — run discovery first |
discovery has never run for that scope | lm source rediscover --scope <tag> (lm_integrator + lm_admin) |
generation abstained: … / generation failed: … |
the card cannot serve the description, the selection failed twice, or the hub has no chat model | rephrase, or write the filter by hand; lm source schema --scope <tag> shows what the card has |
| the title reads like a table name | no spec.title, and the entity declares no label: |
set spec.title, or give the entity a label: |
| a list stops at exactly 1 000 rows | the render cap | narrow it with filters |
API error 403 (forbidden): no details from the server |
you lack the role: lm_integrator for apply and rm, lm_user for the rest |
ask your administrator; no lm command lists your roles (GET /api/bff/v1/me returns them under roles) |
See also
- The demo desk — the sources every screen here reads
- Entities — where the labels, the domain and the
_conflictsledger come from - Processes — react to what the view shows
- The PWA — where a saved view is displayed
- Scopes — the boundary a view renders within