Installer — 60-second Quickstart
Quickstart
Show customer-visible value in under a minute, from data already living somewhere in the customer's estate. This page is for system integrators and installers — ERP, LIMS, or a folder of CSV exports — who want to show value before explaining architecture.
Once per workstation
Install lm
Take the archive for your platform from the Releases page. Five are published: macOS (Intel and Apple Silicon), Linux (x86-64 and arm64), Windows x86-64.
tar -xzf lm_<version>_darwin_arm64.tar.gz # your platform's archive
install -d ~/.local/bin && install -m 0755 lm ~/.local/bin/lm
On Windows the archive is a .zip holding lm.exe — unpack it into a directory on your PATH.
Each release also carries lm_<version>_checksums.txt:
shasum -a 256 -c lm_<version>_checksums.txt --ignore-missing
The archives are named after
lm's own version, which advances on its own tags rather than the hub's — that is whylm_1.3.0_…hangs on av1.7.xrelease.
Building from source works too, and needs Go 1.26+ (the floor is lm/go.mod):
cd lm && make install # builds, then copies to ~/.local/bin (no sudo)
If the shell cannot find lm afterwards, ~/.local/bin is not on your PATH — add it, or run
the binary in place as ./lm.
lm versionprints the release number for a downloaded binary anddevfor one built from source, which sets no version stamp. It is the line to put in a bug report.
Run lumnik
See the deploy docs — deploy/selfhost/up.sh for a single customer. It
generates every secret the hub needs into .env (mode 600), LUMNIK_SECRET_MASTER_KEY
included — nothing to set by hand, and nothing to regenerate.
Running the JVM outside compose (systemd, Kubernetes)? Set
LUMNIK_SECRET_MASTER_KEYyourself (openssl rand -base64 32) — the hub refuses to start without it.
Three gestures are not in the open edition
lm ask, lm source schema and lm source rediscover answer 404 on an open-edition
hub — the chat lives in the closed edition. Everything else on this page runs there.
See which edition you installed.
Point lm at the hub
lm config set-context selfhost --server http://localhost:8080 \
--oidc-issuer http://localhost:8180/realms/lumnik
lm config use-context selfhost
lm login # prints a URL + device code — demo user: integrator (password: INTEGRATOR_PASSWORD in .env)
lm login prints a link and a code, then waits — open the link in any browser, sign in as
integrator, and the terminal continues on its own:
$ lm login
To sign in, open:
http://localhost:8180/realms/lumnik/device?user_code=EYQP-LLJN
and enter code: EYQP-LLJN
Waiting...
The round trip is proven when a listing answers:
$ lm connector list
NAME TYPE TENANT SCHED WINDOW STATUS LAST RUN
An empty table is the right answer on a fresh hub — nothing is registered yet.
Every scenario below assumes this context: on the deployed stack, each lm call is
Bearer-authenticated — without lm login, everything returns 401.
Choose a demo shape
Scenario A — REST via OpenAPI
Generates a manifest from an OpenAPI spec. It applies as generated — but a big spec yields a long one, so you trim the endpoints you don't want first.
Scenario B — one-shot CSV
Best when the customer can hand you one export immediately — and the shortest path from nothing to an answer.
Scenario C — combined
Best when one question should span an API and a file at once — needs Scenario A's manifest first.
Scenario D — nightly SFTP
Best when the customer already has a scheduled export ritual.
Scenario E — S3 prefix
Best for object-store exports or S3-compatible storage like MinIO or R2.
Scenario A — Stripe via OpenAPI
# 1. Register the API key (AES-256-encrypted in DB via pgcrypto)
# No value on the command line: it prompts, and what you type is not shown
lm secret set STRIPE_API_KEY
# 2. Generate the manifest from Stripe's OpenAPI — -o keeps the file so you can trim it
# (add --apply to skip the file and post it straight to the hub)
# (Stripe publishes the spec on GitHub; api.stripe.com/openapi.json is NOT a URL — 404)
lm source rest from-openapi \
https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json \
--name stripe --scope billing -o stripe.yaml
# 3. Check it before you apply it
lm validate -f stripe.yaml
Validate stops at the envelope
If kind or metadata.connector_type is wrong, validate reports only that. Fix it and
run validate again to see the rest.
That spec is about 8 MB and lists Stripe's whole surface, so the generated manifest is long — budget a minute for the fetch and the read, and expect to delete most of the endpoints before you apply. This scenario is not the 30-second one; Scenario B is.
Once it validates, lm apply -f stripe.yaml upserts it and lm connector run stripe
triggers the first ingestion. Two things that cost an hour if you learn them the hard way:
- Endpoint ids are slugged from the whole path, not the resource: Stripe's
/v1/chargesbecomesv1-charges. The gesture islm endpoint disable stripe/v1-charges, neverstripe/charges— lookup is exact-match and an unknown name is simply not found. lm endpoint enablestarts nothing — it only flips a flag. Scheduled ingestion needsspec.scheduleand an enabled connector (lm connector resumeif it was registered dormant by a one-shot);lm connector runneeds neither. Uselm endpoint disableto narrow a noisy import.
Then ask in the scope you declared — the same word you gave from-openapi. Without
--scope, the ask goes to your default scope (lm scope) — system on a fresh install —
where this connector put nothing:
lm ask "how many charges above 1000?" --scope billing
# answers once the connector has COMPLETED a run — the run registers the ext.* table
# and rebuilds the scope's schema card ("What makes a scope answerable" below)
A scope answers only once it holds a schema
Ask-to-SQL reads a schema card built per scope, not the raw tables. If an ask comes back with nothing to query, look before you guess:
lm source schema --scope billing
It prints what the card holds, and when the card is empty it prints the command that rebuilds it. Applying an entity over the scope also rebuilds it.
Scenario B — one-shot CSV
lm csv ingest customers.csv --scope sales
# askable at once in that scope — the last line of the output gives the question to try
What it prints, on a three-row stock file:
$ lm csv ingest stock.csv --scope retail --name stock-count
Ingested stock.csv into ext.stock_count
• rows written: 3
• promoted cols: 4
- sku (text)
- label (text)
- qty (bigint)
- warehouse (text)
• askable: scope 'retail' — try: lm ask --scope retail "how many rows are in ext.stock_count?"
A dormant connector is registered — see `lm connector list`.
ext.<name> table and registers a dormant connector —
nothing runs again on its own, and no run appears in lm run list. The scope is askable
right away, on an edition that carries the chat. Promotion to recurring ingestion comes
after the demo.
Scenario C — combined
Needs the stripe.yaml Scenario A generated — see Scenario A.
lm secret set STRIPE_API_KEY # prompts; the value never reaches your shell history
lm apply -f stripe.yaml # the REST manifest from Scenario A
lm csv ingest contacts.csv --scope billing
lm connector run stripe # apply enabled the endpoints; this is what starts a run
# the CSV half is askable at once; the API half joins the scope once this run completes
Both halves land in the same scope on purpose — --scope billing on the connector's
from-openapi and on the ingest — which is what lets one question span the API and the file.
Scenario D — nightly SFTP export
The most common shape: a customer ERP exports to an SFTP server every night.
lm secret set ACME_SFTP_KEY - # paste the private SSH key on stdin, then Ctrl+D (PowerShell: Ctrl+Z, Enter)
lm csv ingest sftp://lumnik@sftp.acme.io/export/billing-latest.csv \
--key-secret ACME_SFTP_KEY --scope billing --name acme-billing
# askable at once; recurrence is what the promotion below adds
$ lm secret set SFTP_EXPORT_PASSWORD --description "nightly export server"
Value for SFTP_EXPORT_PASSWORD:
Created secret "SFTP_EXPORT_PASSWORD"
$ lm secret list
NAME ROTATED_AT DESCRIPTION
SFTP_EXPORT_PASSWORD - nightly export server
The value is never printed back — not by list, not by any API. Lose it, write it again.
To switch to daily recurrence you declare it: the one-shot leaves no manifest behind and
nothing exports one, so you write the csv-file manifest yourself — fields on
CSV & files — and put it through the same
validate → apply model every manifest follows.
Reuse the exact registered name or you get two connectors. The one-shot slugifies
--name: every run of non-alphanumerics becomes _, so acme-billing was registered as
acme_billing. Apply matches on that name exactly; a near-miss creates a second
connector, enabled from birth, and both then ingest the same files. Confirm with
lm connector list before you write the manifest.
$EDITOR acme-nightly.yaml # a csv-file manifest: metadata.name: acme_billing (the name
# lm connector list shows), metadata.scopes: [billing] (or the
# tables stay un-askable — validate warns), spec.transport
# (kind: sftp, host:, user:, path: /export/*.csv,
# key_env: ACME_SFTP_KEY, host_key_fingerprint: "SHA256:…" —
# refused without it), spec.parser (optional — the
# delimiter is sniffed and a header row assumed),
# spec.schedule: "0 6 * * *", after_process: move,
# archive_path: /archive/
lm validate -f acme-nightly.yaml # names any block you left out, field by field
lm apply -f acme-nightly.yaml
lm connector resume acme_billing # apply upserts by name and never flips enabled: the connector
# the one-shot registered stays dormant until you resume it
Scenario E — S3 prefix
lm secret set AWS_ACCESS_KEY_ID # both prompt; neither value is echoed
lm secret set AWS_SECRET_ACCESS_KEY
lm csv ingest s3://acme-exports/billing/ \
--region eu-west-3 --suffix .csv --scope billing
# askable at once in that scope
Those two secret names are not decoration — they are the defaults the S3 source looks up
(AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), which is why no flag references them. If the
customer's credentials live under any other name, say so explicitly:
lm csv ingest s3://acme-exports/billing/ --region eu-west-3 --scope billing \
--access-key-secret ACME_S3_KEY --secret-key-secret ACME_S3_SECRET
For MinIO, Cloudflare R2, or GCS-S3-compatible endpoints, add --endpoint https://....
From demo to production
Customize the generated manifest
$EDITOR stripe.yaml # the file Scenario A's -o kept — set spec.schedule, promote
# columns, adjust pagination, etc.
lm validate -f stripe.yaml # optional but recommended — field-by-field errors before apply
lm apply -f stripe.yaml
spec.schedule is the cron the hub's scheduler selects on. A default_schedule key inside
the connector config is read by nothing and schedules nothing.
Generate with -o or you lose the manifest: lm describe reads connector-type schemas
(lm describe connector-type csv-file), never an applied connector.
Promote the CSV to recurring ingestion
Coming in v1.1 via lm source csv add --path /watched/*.csv --schedule "*/5 * * * *".
Until then the escape hatch is the one in
Scenario D: write the csv-file manifest under the
connector's own metadata.name, lm apply -f it, then lm connector resume it.
Enter a secret without a shell trace
lm secret set STRIPE_API_KEY
# (prompts: type or paste the value, it is not shown)
Keeps the value out of shell history and ps -ef — passing it as an argument still works, and
says so on stderr. For a value that spans lines (a private SSH key) use lm secret set NAME -
and end with Ctrl+D — Ctrl+Z then Enter in PowerShell; that form reads until EOF, and warns
that a terminal shows what you paste. The CLI names the right gesture for the shell you are in.
Production security
- Master-key rotation: planned for v1.1 (
lm secret rotate-master-key) - Audit: every secret write, and every read from the database, emits a
platform.secret.{created,updated,deleted,read}event - RBAC: creating, updating and deleting secrets requires the realm role
lm_admin - No REST endpoint ever returns a secret's value — if the installer loses it, they rewrite it
How to…
Show the customer what the demo actually read
lm connector list # the registered names
lm connector run <connector> # nothing runs on its own until spec.schedule is set
lm run list --connector <connector>
lm run get <ID>
$ lm connector list
NAME TYPE TENANT SCHED WINDOW STATUS LAST RUN
customers csv-file 1 Completed 2026-09-05 05:38
orders csv-file 1 Completed 2026-09-05 05:38
products csv-file 1 Failed 2026-09-05 05:38
crm-contacts csv-file 1 Completed 2026-09-05 05:39
$ lm run list --connector orders
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
f0466804 orders default Completed 10 8 2 2 2026-09-05 05:38 2026-09-05 05:38
$ lm run get f0466804
ID CONNECTOR ENDPOINT STATUS IN OUT SKIPPED DEAD-LETTERS STARTED ENDED
f0466804 orders default Completed 10 8 2 2 2026-09-05 05:38 2026-09-05 05:38
⚠ 2 rows quarantined — see: lm dlq list --run f0466804-0824-4eb4-8aa2-54e263dccafe
IN, OUT, SKIPPED, DEAD-LETTERS — and the
arithmetic IN = OUT + SKIPPED holds on every run, so every row read is accounted for,
written or dropped. lm run get also names a failure and points at lm dlq list --run <ID>
when rows were quarantined.
Two ways to end up staring at an empty table:
- No run has happened. Scheduled runs need
spec.scheduleand an enabled connector; apply never flipsenabled, so a connector registered dormant by a one-shot needslm connector resume <name>too.lm connector run <name>starts one by hand regardless. - You took a one-shot path.
lm csv ingestrecords no run — Scenarios B, D and E leavelm run listempty by design.
Take the demo back off the customer's machine
lm connector list # the registered (slugified) names
lm connector delete acme_billing
$ lm connector delete stock_demo
deleted: stock_demo
One gesture takes everything with it: the connector, its endpoints, its hub table
(connector.t_<name> for a manifest connector, ext.<name> for a one-shot) and every RAG
chunk it produced. A connector whose table feeds a fused entity is the
exception — the entity's view depends on the table, so the delete is refused and names
the entity to remove first:
$ lm connector delete cust-csv
Error: API error 409: table connector.t_cust_csv feeds entity customer — remove the entity first (lm entity delete customer)
$ lm entity delete customer
deleted: customer
$ lm connector delete cust-csv
deleted: cust-csv
(The entity name in the message is lowercase — the hub derives it from the view name
connector.v_customer, and lm entity delete resolves the name case-insensitively.)
What makes a scope answerable
lm ask always asks in analytic mode, and the analytic pipeline's first act — before any
model is touched — is to load a schema card for the scope. No card, no answer:
the ask refuses immediately.
A card is written three ways: when a connector run completes with a new or changed
table (Scenario A, after its first run), when a one-shot lm csv ingest commits
(Scenarios B, D and E — askable the moment the command returns), and when an
entity is applied over the scope. Nothing to configure: the card appears
on its own, and lm source schema --scope <tag> shows what it holds.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Bearer token unavailable (env=STRIPE_API_KEY) |
secret set neither in DB nor as an env var | lm secret list then lm secret set STRIPE_API_KEY |
apply failed: … (400) right after from-openapi --apply |
the hub refused the manifest — a hand-edit broke it, or metadata.tenant was added and does not match your own |
generate with -o instead, then lm validate -f to see which field the hub names |
a second connector appears after lm apply -f |
metadata.name did not match the registered (slugified) name, so apply created rather than updated |
lm connector list for the real name, lm connector delete the duplicate, re-apply |
429 Too Many Requests from the vendor |
too many calls for the quota | edit default_rate_limit: 50/s in the YAML, then lm apply -f |
lumnik.secret.master-key is too short at JVM startup |
env var missing or < 16 chars | regenerate with openssl rand -base64 32 + export; restart the JVM |
secret with this name already exists on secret set |
the create door refuses a name a live secret holds; lm falls back to an update, a direct POST does not |
nothing to do through lm. To replace rather than rotate: lm secret rm NAME --force, then set — a removed name is free again |
403 {"error":"scope not granted"} on lm apply |
your user holds scope: roles and the manifest declares a scope you do not hold (or none) |
declare only scopes you hold, or apply as a user with no scope: role |
WARN metadata.scopes — no scopes declared on validate |
the manifest names no métier, so its tables will not be askable | add scopes: [<scope>] under metadata |
For connector details: REST.