The WOW — two legacy systems, one customer
Show, don't explain
A Siebel CRM over SOAP and a CSV export become one Customer. Then the data starts working: a view, a decision, a lifecycle.
┌─ fake-siebel.py (SOAP) ──→ siebel-customers ─→ connector.cust_siebel ┐
│ ├─→ Customer (fused on customer_code)
└─ customer.csv (export) ──→ cust-csv ─────────→ connector.t_cust_csv ─┘
Before you start
Check each ✔ line. A missing piece fails silently later.
1. The stack, with the demo folder mounted. The hub must see the demo files under /demo:
./deploy/selfhost/up.sh --build
docker compose -f docker-compose.selfhost.yml \
-f docs/demo/wow-customer/docker-compose.wow.yml up -d hub
- Wait one minute: the hub restarts. An
lmcall before that answers401. - ✔
docker compose -f docker-compose.selfhost.yml exec hub ls /demolistscustomer.csv. - A later
up.shkeeps this mount. Afterdown.sh, run the second command again. - The steps marked (Pro) need Ollama and its models: Prerequisites.
2. The fake Siebel, left running in its own terminal:
python3 docs/demo/wow-customer/fake-siebel.py
- ✔
curl -s -X POST http://localhost:8077/eai | head -c 60prints<soap:Envelope xmlns:soap=…. Address already in usemeans it already runs.
3. lm, logged in as integrator (Quickstart, steps 1 and 2).
Act 1 — two sources, one customer
1. Land both sources
lm apply -f docs/demo/wow-customer/siebel-connector.yaml
lm connector run siebel-customers
lm apply -f docs/demo/wow-customer/csv-connector.yaml
lm connector run cust-csv
The two manifests and the CSV
# Source 1 — the "legacy CRM": our fake Siebel over SOAP CustomerQueryPage.
# kind: soap is a request-shape on the HTTP engine (connector_type stays rest-generic).
# Lands canonical hub table connector.cust_siebel — the manifest fuses on its promoted columns.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: siebel-customers
connector_type: rest-generic
scopes: [demo] # the same scope as its CSV sibling: one bound user, both sources
spec:
# fake-siebel.py runs on the HOST. From the hub container (self-host stack) reach it via
# host.docker.internal; if you run the hub with `quarkus:dev` on the host, use localhost:8077.
base_url: http://host.docker.internal:8077
endpoints:
- id: customers
kind: soap
path: /eai
soap_action: CustomerQueryPage
body: |
<In><PageSize>{{page_size}}</PageSize><StartRowNum>{{cursor}}</StartRowNum></In>
pagination:
kind: soap
page_size: 100
last_page_path: "$.Body.Out.LastPage"
response_path: "$.Body.Out.ListOfCustomer.Customer"
incremental:
mode: full
target:
kind: hybrid
table: connector.cust_siebel
unique_key: "$.CustCode"
promote:
- { name: customer_code, path: "$.CustCode", type: text }
- { name: name, path: "$.Name", type: text }
- { name: segment, path: "$.Segment", type: text }
# Source 2 — the "flat-file export": a CSV the second system spits out nightly.
#
# For csv-file connectors the hub table name is NOT configurable: it is
# connector.t_<slug(metadata.name)>
# i.e. a hardcoded `t_` prefix + the slugified connector name. So:
# metadata.name: cust-csv -> connector.t_cust_csv
# (that's the table the entity manifest fuses against). `target` is optional and carries
# only `indexes` — there is no table_prefix knob for csv-file.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: cust-csv # -> connector.t_cust_csv
connector_type: csv-file
scopes: [demo]
spec:
transport:
kind: local
# Path AS SEEN BY THE HUB. On the self-host stack the demo folder is mounted at /demo
# (docker-compose.wow.yml); with `quarkus:dev` on the host, use ./customer.csv.
path: /demo/customer.csv
parser:
has_header: true # header row drives the column names (code, cname, email, ville)
after_process: none # leave the source file in place
code,cname,email,ville
C-100,Jean Dupont,jd@x.fr,Paris
C-200,MARIE A.,m@x.fr,Lyon
C-300,Solo,solo@x.fr,Nice
C-500,Claire Martin,claire@x.fr,Lyon
C-600,Pierre Corneille,corneille@x.fr,Paris
C-700,Maurice Druon,druon@x.fr,Paris
C-800,Albert Camus,camus@x.fr,Lyon
C-900,Saint Exupery,exupery@x.fr,Lyon
run scheduled is not run succeeded. ✔ Both runs must read Completed:
$ lm run list
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
3e9f353a cust-csv default Completed 8 8 0 0 2026-10-24 21:57 2026-10-24 21:57
9d1792ef siebel-customers customers Completed 2 2 0 0 2026-10-24 21:57 2026-10-24 21:57
| You see | Fix |
|---|---|
siebel-customers Failed |
Start the fake Siebel, then run the connector again. |
cust-csv Completed with 0 / 0 |
The /demo mount is missing. Mount it, then run the connector again. |
2. Fuse them
$ lm entity apply -f docs/demo/wow-customer/customer-entity.yaml
Applied entity "Customer" → connector."v_customer"
An error about a relation that "does not exist" means a run above did not land.
The entity manifest
# The hero entity: one Customer fused from the two legacy sources on customer_code.
# Siebel is listed first, so on a disagreement Siebel is the representative and the CSV
# value is preserved in the _conflicts ledger (nothing is silently dropped).
kind: Entity
name: Customer
description: "A customer, fused from the Siebel CRM and the nightly CSV export."
match: [customer_code]
scopes: [clients] # the scope(s) whose chat can ask about this entity
columns: # grounding fed to the chat + human labels a kind:View renders
customer_code:
label: "Code client" # shown by kind:View instead of the raw canonical name
name:
label: "Nom"
segment:
label: "Segment"
description: "Commercial segment."
values: [GOLD, SILVER, BRONZE] # the domain — a filter on any other value is REFUSED, not run
city:
label: "Ville"
description: "Billing city (free text, French city names)."
example: "Lyon"
sources:
- table: cust_siebel # source 1 (SOAP/Siebel) — promoted columns
map:
customer_code: customer_code
name: name
segment: segment
- table: t_cust_csv # source 2 (CSV export) — header columns
map:
code: customer_code
cname: name
email: email
ville: city
3. Read the fused customer
$ lm entity get customer
CUSTOMER_CODE NAME SEGMENT EMAIL CITY _CONFLICTS
C-100 Jean Dupont GOLD jd@x.fr Paris {}
C-200 Marie SILVER m@x.fr Lyon {"name": [{"src": "cust_siebel", "value": "Marie"}, {"src": "t_cust_csv", "value": "MARIE A."}]}
C-300 Solo solo@x.fr Nice {}
C-500 Claire Martin claire@x.fr Lyon {}
C-600 Pierre Corneille corneille@x.fr Paris {}
C-700 Maurice Druon druon@x.fr Paris {}
C-800 Albert Camus camus@x.fr Lyon {}
C-900 Saint Exupery exupery@x.fr Lyon {}
- C-100: both systems agree. The email and the city come from the CSV.
- C-200: the systems disagree on the name. Siebel is listed first in the manifest, so its
value shows. Both values are kept in
_conflicts. - The others exist only in the CSV: no segment, no conflict.
4. Ask it (Pro)
The entity declares scopes: [clients]. Applying it made the customer askable:
$ lm ask --scope clients "combien de clients à Lyon ?"
Il y a 4 clients à Lyon.
COUNT
4
1 row returned
SQL: SELECT COUNT(*) FROM "connector"."v_customer" WHERE "city" = 'Lyon'
LIMIT 500
The answer reads the fused customer, and shows the SQL it ran.
5. It never answers a value that does not exist
The entity declares segment with three values: GOLD, SILVER, BRONZE. A question about
another value gets no number (Pro):
$ lm ask --scope clients "combien de clients du segment PLATINUM ?"
Je ne peux pas répondre à partir des données disponibles pour ce périmètre — la question ne correspond pas au schéma déclaré.
A view filter on that value is refused too, on every edition:
$ lm view -f docs/demo/wow-customer/clients-view.yaml --filter segment=PLATINUM
Error: API error 400: 'PLATINUM' is not an allowed value for segment (allowed: GOLD, SILVER, BRONZE)
PLATINUM is real? Add it to the entity manifest.
Act 2 — the data works for you
6. See it (kind: View)
clients-view.yaml declares a list over the fused customer:
$ lm view -f docs/demo/wow-customer/clients-view.yaml --filter city=Lyon
Clients · city=Lyon
Code client Nom Ville Segment
C-200 Marie Lyon SILVER
C-500 Claire Martin Lyon
C-800 Albert Camus Lyon
C-900 Saint Exupery Lyon
$ lm view C-200 -f docs/demo/wow-customer/clients-view.yaml
Clients / C-200
Code client C-200
Nom Marie
Ville Lyon
Segment SILVER
(!) Ambiguïtés et divergences
Nom cust_siebel «Marie» · t_cust_csv «MARIE A.»
- The detail shows the disagreement, with each system's value.
- Quote a threshold filter:
--filter "montant>1000". Unquoted,>is a shell redirect.
The view manifest
# The first per-scope app surface: a list over the fused Customer entity (scope: clients).
# Read-only, card-backed — every column must exist in the clients SchemaCard or the render is refused.
apiVersion: apps.lumnik.io/v1
kind: View
metadata:
name: clients-desk
spec:
scope: clients
table: connector.v_customer
title: "Clients"
columns: [customer_code, name, city, segment]
Let the AI write a view (Pro). Describe it in a sentence:
$ lm view generate clients "les clients gold avec leur ville et segment" -o gold.yaml
manifest written to gold.yaml — review it, then: lm view -f gold.yaml
$ head -3 gold.yaml
# asked for: les clients gold avec leur ville et segment
# kept: segment=GOLD — a condition of that description not listed here is NOT enforced
# lm view -f gold.yaml --filter 'segment=GOLD'
# kept:lists the conditions the view applies. Check it against what you asked.- The third line is the command that renders your file.
-
A request the scope cannot answer is declined:
$ lm view generate clients "les commandes du mois de mars" Error: API error 400: generation abstained: The provided tables and columns do not include any information about orders or sales data.
7. React to it (kind: Process)
A rule watches the CSV. When a customer from Lyon arrives, it raises a decision. Your webhook receives it; here, a fake n8n that prints what it gets.
python3 docs/demo/wow-customer/fake-n8n.py # in its own terminal, left running
echo -n "demo-secret" | lm integration add fake-n8n \
--url http://host.docker.internal:9099/hook --events 'decision.*' --secret-stdin
lm process apply -f docs/demo/wow-customer/lyon-process.yaml
registered: fake-n8n (id 1000) — forwards subscribed events from now on
Applied process "lyon-client-arrive" → table connector.t_cust_csv, emits decision.lyon-client-arrive
The process manifest
# The dragon's first rung, PROVEN LIVE: when a new customer from Lyon lands in the CSV,
# emit a decision event. The effect IS the event — delivery rides the existing webhook
# subscriptions (n8n/Make/Zapier), zero new code.
#
# Why `ville` and scope `demo` (not `segment`/`clients`): a process triggers on
# data.row.changed, born at the RAW write path — so on.table is the raw ingest table
# `connector.t_cust_csv`, and its columns are the CSV's own (code, cname, email, ville).
# `segment` is a Siebel field, absent from the CSV. The raw table lives in the `demo` scope
# card (the csv connector is tagged `demo`); the fused `clients` card names only v_customer.
apiVersion: apps.lumnik.io/v1
kind: Process
metadata:
name: lyon-client-arrive
spec:
scope: demo
on:
event: data.row.changed
table: connector.t_cust_csv
when:
- ville=Lyon
emit:
type: decision.lyon-client-arrive
message: "Nouveau client {cname} à {ville}"
A process sees only the rows that land after it is applied. The four Lyon customers already there raise nothing. Add one the file does not have, then run the connector again:
echo 'C-400,Camille Roux,camille@x.fr,Lyon' >> docs/demo/wow-customer/customer.csv
lm connector run cust-csv
A few seconds later, the fake n8n prints the decision (entity_key shortened here):
[fake-n8n] POST /hook received:
{
"entity": "connector.t_cust_csv",
"fields": {
"cname": "Camille Roux",
"ville": "Lyon"
},
"message": "Nouveau client Camille Roux à Lyon",
"process": "lyon-client-arrive",
"entity_key": "ff1027320a1a…"
}
fields carries the columns the message names: n8n reads values, it does not parse text.
8. Decide (lm inbox)
The same decision waits in the inbox. A person treats it:
$ lm inbox
ID WHEN PROCESS MESSAGE ENTITY_KEY
8 just now lyon-client-arrive Nouveau client Camille Roux à Lyon ff1027320a1acbe2646ce1b2d1e4a42111868a75bb30825dc4609d785e6aaa7f
$ lm inbox ack 8
acked.
lm inbox --allalso shows the treated ones, marked[acked].- The
echochanged a file of the clone.git checkout docs/demo/wow-customer/customer.csvrestores it.
Act 3 — the lifecycle (kind: Workflow)
9. Declare what a status means
commandes.csv holds dirty statuses: En cours, fini, TERMINE with a trailing space,
encours. Twice it holds EN LITIGE, which nobody declared. Land it first, then declare the
lifecycle:
lm apply -f docs/demo/wow-customer/commandes-connector.yaml
lm connector run commandes-csv
lm workflow apply -f docs/demo/wow-customer/commande-workflow.yaml
$ lm workflow apply -f docs/demo/wow-customer/commande-workflow.yaml
Applied workflow "commande-lifecycle" → connector.t_commandes_csv.statut, scope demo
in the data, not declared: EN LITIGE (2 rows)
EN LITIGE, 2 rows: it is in the data and in no declared state. The apply reports it and succeeds.- No
declared, not in the dataline: every declared state was seen in the data. - Applied before the run, the report says
the table is not there yet.
The connector and the workflow
# Act 3 source — the commandes export: the same nightly CSV habit as csv-connector.yaml,
# now landing the raw table the workflow act declares a lifecycle over.
#
# For csv-file connectors the hub table name is NOT configurable: it is
# connector.t_<slug(metadata.name)>
# i.e. a hardcoded `t_` prefix + the slugified connector name. So:
# metadata.name: commandes-csv -> connector.t_commandes_csv
# (that's the table commande-workflow.yaml's spec.on.table names). `target` is optional and
# carries only `indexes` — there is no table_prefix knob for csv-file.
apiVersion: connectors.lumnik.io/v1
kind: Connector
metadata:
name: commandes-csv # -> connector.t_commandes_csv
connector_type: csv-file
scopes: [demo]
spec:
transport:
kind: local
# Path AS SEEN BY THE HUB. On the self-host stack the demo folder is mounted at /demo
# (docker-compose.wow.yml); with `quarkus:dev` on the host, use ./commandes.csv.
path: /demo/commandes.csv
parser:
has_header: true # header row drives the column names (num, client, statut, date_expedition)
after_process: none # leave the source file in place
# Declare the document lifecycle of a column instead of teaching the chat its values by
# hand: states + labelled aliases (Excel reality is dirty: "en cours"/"encours" both mean
# the same thing) + a transition graph. provenance MUST be `declared` — v1 accepts no
# other value. `on.table`/`on.column` name the raw ingest column whose values this
# taxonomy explains; apply reports the data-truth confrontation: undeclared VALUES found
# in the data (reality, never refused — 'EN LITIGE' is one) and unobserved CODES declared
# but never seen.
apiVersion: apps.lumnik.io/v1
kind: Workflow
metadata:
name: commande-lifecycle
spec:
scope: demo
provenance: declared
# the sweep alerts a non-terminal commande observed unchanged past 30 days —
# observation-relative, see docs/apps/workflows.md
dormancy:
after: 30d
on:
table: connector.t_commandes_csv
column: statut
key: num
states:
- code: BROUILLON
initial: true
- code: EN_COURS
label: "En préparation"
aliases: [en cours, encours]
- code: TERMINE
aliases: [fini]
terminal: true
transitions:
- { from: [BROUILLON], to: EN_COURS }
- { from: [EN_COURS], to: TERMINE }
10. Ask the lifecycle (Pro)
The chat answers from the declared workflow:
$ lm chat
lm chat — type :help for commands, :exit to quit
[corpus=* scope=default topK=10] > :scope demo
scope → demo
[corpus=* scope=demo topK=10] > quels sont les états d une commande et quelles transitions sont autorisées ?
Les états d'une commande sont les suivants :
* BROUILLON (initial)
* EN_COURS (« En préparation », alias : en cours, encours)
* TERMINE (terminal, alias : fini)
Les transitions autorisées entre ces états sont :
* BROUILLON -> EN_COURS
* EN_COURS -> TERMINE
The states, aliases and transitions are the manifest's, word for word. The wording changes from one run to the next; the facts must not.
11. Silence becomes a signal
The workflow also declares dormancy.after: 30d. An order left in the same non-terminal state
for 30 days raises an alert in lm inbox.
- The 30 days count from the day lumnik first saw the order.
- To see it now, set
after: 1min a copy of the manifest and apply it. The check runs every 15 minutes: allow up to 30 minutes.
With after: 1m:
$ lm inbox
ID WHEN PROCESS MESSAGE ENTITY_KEY
23 just now workflow:commande-lifecycle CMD-007: observed unmoved in "EN LITIGE" since 2026-10-18 (first observed 2026-10-18) — declared threshold 1m in commande-lifecycle CMD-007
22 just now workflow:commande-lifecycle CMD-006: observed unmoved in "EN_COURS" since 2026-10-18 (first observed 2026-10-18) — declared threshold 1m in commande-lifecycle CMD-006
21 just now workflow:commande-lifecycle CMD-005: observed unmoved in "EN LITIGE" since 2026-10-18 (first observed 2026-10-18) — declared threshold 1m in commande-lifecycle CMD-005
20 just now workflow:commande-lifecycle CMD-004: observed unmoved in "BROUILLON" since 2026-10-18 (first observed 2026-10-18) — declared threshold 1m in commande-lifecycle CMD-004
19 just now workflow:commande-lifecycle CMD-001: observed unmoved in "EN_COURS" since 2026-10-18 (first observed 2026-10-18) — declared threshold 1m in commande-lifecycle CMD-001
CMD-002andCMD-003never alert:TERMINEis a terminal state.EN LITIGEis undeclared, so never terminal: its orders alert like the others.
More: Dormancy.
After the demo — the customer's own data
- Point at the real database.
lm source jdbc test, thendiscover, thenadd. ASELECT-only account is enough. The guided flow - Repair at the door. On a file export or a REST endpoint, transformers fix broken accents and legacy dates. A database table is read as it is: rename a column in its manifest, or read a view of the source.
- Name a scope, run, ask (Pro). What makes a scope answerable
What takes calendar time is outside lumnik: the SELECT account, the network path.