The WOW, step by step¶
Two heterogeneous sources — a SOAP Siebel and a CSV — fused into one canonical Customer, served over REST, and asked in natural language. Every file below is the real file the end-to-end test runs, so this walkthrough can never drift from what actually works.
1. A SOAP source (fake Siebel)¶
The connector manifest:
# 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
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 }
2. A CSV source¶
# 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` only carries optional
# `indexes`/`tags` — 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
tags: [demo]
spec:
target: # the target block itself is required (ConfigDef error if absent)
tags: [demo] # tags is recommended: silences a warning + gives the RAG scope its filter
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
Its data:
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
3. Fuse into a canonical Customer¶
The entity manifest (match + merge) that unifies both sources:
# 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 métier(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
4. Ask it¶
lm ask --scope clients "combien de clients à Lyon ?"
# → an answer computed over a fused truth that lived in neither Siebel nor the CSV alone,
# with the SQL that ran shown beneath it.
Act 2 — the data starts working for you.
5. See it (kind: View)¶
A declarative, read-only list over the fused Customer — reference:
# The first per-métier 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]
lm view -f docs/demo/wow-customer/clients-view.yaml --filter city=Lyon
Or skip writing the YAML — describe the view and let the AI write it, validated by the same gate (an unknown column can never slip through, and an unanswerable ask is refused with the reason):
lm view generate clients "les clients gold avec leur ville et segment" -o gold.yaml
cat gold.yaml # the first line is the ready-to-run render command
lm view -f gold.yaml --filter 'segment=GOLD'
6. React to it (kind: Process)¶
When a new Lyon customer lands, emit a decision — delivery rides your existing webhook subscriptions (n8n here) — reference:
# 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}"
lm process apply -f docs/demo/wow-customer/lyon-process.yaml
# or create it in the TUI: `:new` → Process — the same living example opens in $EDITOR
7. Decide (inbox → ack)¶
The human disposes — and the ack is itself an event, idempotent by construction:
lm inbox
# ID QUAND PROCESS MESSAGE ENTITY_KEY
# 1 2m lyon-client-arrive Nouveau client Claire à Lyon CUST-042
lm inbox ack 1
# acked. (re-acking returns the same 200 — one outbox row, ever)
8. Declare the lifecycle (kind: Workflow)¶
The three rungs above act on rows. This one teaches the substrate what the rows mean: the states a document moves through, the aliases twenty years of Excel produced for them, and which are terminal — reference.
# 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 }
lm workflow apply -f docs/demo/wow-customer/commande-workflow.yaml
# {"name":"commande-lifecycle","scope":"demo", … ,
# "dataTruth":{"undeclared":[{"value":"EN LITIGE","rows":2}], … }}
Applying it doesn't hide the mess, it names it: EN LITIGE sits in the column and in no
state, so the response reports it — and still returns 200, because reality doesn't ask the
manifest's permission. The declared graph then lands in the scope's corpus, so the chat
answers a lifecycle question from it instead of inventing one.
The manifest also declares spec.on.key (num) and spec.dormancy.after: 30d — a scheduled
sweep watching every commande for the one thing the raw table itself cannot say: how long it
has sat, unmoved, in a non-terminal state. Cold start is honest — nothing can be dormant before
30d elapses from lumnik's own first observation, never from the source's unknown history —
and the alert never claims a source-absolute duration. Captured from a live replay with the
threshold shortened to 1m so the sweep could fire without the real 30-day wait (the committed
manifest still declares 30d):
lm inbox
# ID QUAND PROCESS MESSAGE ENTITY_KEY
# 2 1m workflow:commande-lifecycle CMD-005 : observé immobile en « EN LITIGE » depuis … CMD-005
CMD-002 and CMD-003 never alert — both resolve to the terminal TERMINE, which the sweep
excludes. EN LITIGE — undeclared, and therefore never terminal — is watchable too: CMD-005
and CMD-007 sleep in it and alert exactly like any other stalled commande. See
Dormancy for the full vocabulary.
Continue hands-on: the full by-hand replay — on the
deployed self-host stack, real OIDC, lm login and Bearer-authenticated calls
throughout (including the fake-siebel.py SOAP stub and the demo credentials).