Skip to content

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.

Time

About 30 minutes, on the self-host stack.

Files

All in docs/demo/wow-customer/. Each command below runs from the root of the clone.

Edition

Every step runs on lumnik Open, except the parts marked (Pro).

No Siebel at hand

fake-siebel.py answers the SOAP call. Everything else is real lumnik.

┌─ 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 lm call before that answers 401.
  • ✔ docker compose -f docker-compose.selfhost.yml exec hub ls /demo lists customer.csv.
  • A later up.sh keeps this mount. After down.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 60 prints <soap:Envelope xmlns:soap=….
  • Address already in use means 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 --all also shows the treated ones, marked [acked].
  • The echo changed a file of the clone. git checkout docs/demo/wow-customer/customer.csv restores 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 data line: 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: 1m in 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-002 and CMD-003 never alert: TERMINE is a terminal state.
  • EN LITIGE is undeclared, so never terminal: its orders alert like the others.

More: Dormancy.

After the demo — the customer's own data

  1. Point at the real database. lm source jdbc test, then discover, then add. A SELECT-only account is enough. The guided flow
  2. 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.
  3. Name a scope, run, ask (Pro). What makes a scope answerable

What takes calendar time is outside lumnik: the SELECT account, the network path.