RAG over HTTP — the chat, search and scope API¶
Everything lm ask, the TUI :ask and the métier PWA do rides a small set of JSON-over-HTTP
endpoints on the hub. This page is the contract for calling them yourself — from a script, an
integration, or an app you build on the substrate. The PWA at /app/ is the living example: its
chat panel is one POST /api/rag/chat with mode: "analytic"
(lumnik-hub/src/main/resources/META-INF/resources/app/src/components/chat.mjs), nothing more.
All requests and responses are application/json unless noted. All example payloads below are
illustrative — field names and shapes are exact, values are made up.
The two doors¶
The same chat lives behind two paths with different audiences:
| Door | Path | Who | Guarded by |
|---|---|---|---|
| User | POST /api/rag/chat |
end users (lm_user role) — the PWA's door |
@RequiresPermission("lm_user") |
| Platform | POST /api/platform/rag/chat + everything else under /api/platform/rag/* |
integrators (lm_integrator role) — lm, scripts, tooling |
PlatformApiAuthFilter on the whole /api/platform/* prefix |
Behavior is identical — the user door delegates to the platform one, so tenant validation, the
métier-scope binding and the
honesty guards ride through unchanged. Only the required role
and the rate-limit bucket differ. If you are building an app for end users, use /api/rag/chat;
/api/platform/* stays the integrator surface.
Two admin-only endpoints (status, reindex, schema, rediscover) additionally require
lm_admin — and, living under /api/platform/*, they pass through the integrator filter too,
so the token must carry both roles.
Authentication¶
Every call carries a Bearer JWT:
Authorization: Bearer <access-token>
The token comes from your OIDC issuer — the same one lm login uses (see
Point lm at the hub). After lm login, the short-lived
access token sits in ~/.lm/config.yaml under access: (auto-refreshed by lm; if a curl call
starts answering 401, run any lm command or lm login again). Scripts driving lm itself can
instead set a static token via the LUMNIK_TOKEN environment variable (see the
lm reference).
The tenant is resolved from the token, never from a client-supplied header — an
authenticated caller cannot point a request at another tenant's data. (In dev with
lumnik.security.dev-bypass=true, auth is bypassed and tenant/user come from the
X-Tenant-ID / X-User-ID headers instead.)
Chat¶
POST /api/rag/chat (lm_user)
POST /api/platform/rag/chat (lm_integrator)
One endpoint, two modes, selected by the mode field:
| Field | Type | Meaning | Default |
|---|---|---|---|
question |
string | the natural-language question — required (blank → 400) | — |
mode |
string | "analytic" (case-insensitive) = text-to-SQL over the ingested hub; anything else or absent = semantic retrieval + chat |
semantic |
scope |
string | the métier tag. "all" and blank mean "no specific scope". Semantic: filters retrieval to chunks carrying the tag. Analytic: names the schema card to ask over — an unscoped user falls back to system; a scope-bound user must name a granted scope |
none / system |
corpus |
string | semantic only: restrict retrieval to one corpus (usually a connector source, see corpora) | all corpora |
topK |
integer | semantic only: how many chunks to retrieve as context | 10 |
corpus and topK are silently ignored in analytic mode.
Semantic response¶
The question is embedded, the top-K most similar chunks are retrieved (tenant-isolated, tag- and
corpus-filtered), and the LLM answers from that context only, citing chunks inline with
[row:hash] tags:
{
"answer": "Three customers are based in Lyon: Acme SARL [row:a3f9c2], Bistrot Lumière [row:7d01be] ...",
"citations": ["a3f9c2...", "7d01be...", "..."]
}
citations lists the row hashes of every retrieved chunk — the context the model saw. The
[row:hash] tags inside answer are the ones it says it used. The model is instructed to say so
explicitly when the context cannot answer the question.
Analytic response (mode: "analytic")¶
The question is translated to a single read-only SELECT over the scope's declared schema,
guarded, executed, and phrased back:
{
"answer": "There are 42 open orders, for a total of 18 750,00 €.",
"sql": "SELECT count(*), sum(grand_total) FROM connector.c_order WHERE status = 'open'",
"rowCount": 1,
"rows": [ { "count": 42, "sum": 18750.00 } ]
}
sqlis always the query that ran (or was refused) — verification is one glance away.rowsis the raw result, one{column: value}map per row, capped at 500 rows (a largerLIMITin the generated SQL is lowered).- Execution is read-only, tenant-scoped by Row-Level Security, with a statement timeout.
How honesty surfaces in the payload¶
The five deterministic guards apply to every analytic call —
this page won't re-explain them, but you need to know how a refusal arrives: as a normal
200 analytic response, with the guard's refusal message as the answer, rowCount: 0 and
rows: []. The sql field carries the refused query (so you can see what was refused), or
"" when the model abstained before any SQL existed:
{
"answer": "I won't run this: 'segment_gold' is not a declared value of segment (known: ...). If it should exist, add it to the entity manifest.",
"sql": "SELECT count(*) FROM connector.c_bpartner WHERE segment = 'segment_gold'",
"rowCount": 0,
"rows": []
}
An app consuming this door should treat a rowCount: 0 answer as "read the answer text", not
as "there are none" — that asymmetry is the whole point. The PWA renders these as a distinct
"refusal" bubble rather than a red error: a refusal is an answer, not a broken surface.
Refusals that happen before the ask — a scope-bound user naming a scope they don't hold —
are HTTP errors instead: 403 {"error":"scope not granted"} (see
scopes).
Search¶
POST /api/platform/rag/search (lm_integrator)
The raw retrieval layer under the semantic chat — embed a query, get the nearest chunks, no LLM answer:
| Field | Type | Meaning | Default |
|---|---|---|---|
query |
string | the search text — required (blank → 400) | — |
topK |
integer | number of hits | 10 |
corpus |
string | restrict to one corpus (source) | all |
scope |
string | restrict to chunks carrying this métier tag; "all"/blank = no tag filter (for a scope-bound user, rewritten onto their granted set — never unfiltered) |
none |
filters |
object | accepted in the body but not applied yet — reserved; don't rely on it | — |
{
"results": [
{
"rowHash": "a3f9c2...",
"content": "Acme SARL, Lyon, segment: premium, since 2019 ...",
"similarity": 0.87,
"metadata": { "row_hash": "a3f9c2...", "table": "c_bpartner", "...": "..." }
}
]
}
similarity is cosine similarity (1 = identical direction). The same scope binding applies: a
scope outside a bound user's grants → 403 {"error":"scope not granted"}.
Scopes¶
GET /api/platform/rag/scopes (lm_integrator)
Lists the scopes that exist in this tenant — remember, a scope is a connector tag, so this is derived from the ingested chunks:
[
{ "scope": "clients", "connectors": 2, "chunks": 1240, "lastIndexed": "2026-07-20T09:14:03Z" },
{ "scope": "stock", "connectors": 1, "chunks": 310, "lastIndexed": "2026-07-19T17:02:41Z" }
]
lastIndexed is an ISO instant, or "" if unknown. A scope-bound user sees only their
granted métiers in this list.
Corpora¶
GET /api/platform/rag/corpora (lm_integrator)
Lists the corpora with indexed chunks. A corpus is usually a connector source, but not
always: applying a kind: Workflow indexes its declared lifecycle
under workflow:<name>, which is backed by a manifest rather than an ingest run.
[
{ "name": "siebel-accounts", "chunkCount": 1240 },
{ "name": "erp-orders", "chunkCount": 310 }
]
This listing is scope-bound too: a scope-bound user sees only corpora carrying at least one granted métier tag — and an untagged corpus belongs to no métier, so it is invisible to a bound user.
Schema (admin)¶
GET /api/platform/rag/schema?scope=<tag> (lm_admin)
POST /api/platform/rag/rediscover?scope=<tag> (lm_admin)
schema returns the scope's schema card — the exact tables/columns (with business labels,
descriptions, examples and declared value-domains) the analytic mode is allowed to query.
404 if no card exists for the scope; run discovery first. rediscover rebuilds the card from
the ingested tables and returns a summary:
{ "scope": "clients", "tableCount": 3, "columnCount": 41, "enriched": true, "sourceType": "connector" }
Status and reindex (admin)¶
GET /api/platform/rag/status (lm_admin)
POST /api/platform/rag/reindex (lm_admin)
{ "indexed": true, "chunks": 1550, "indexedAt": "2026-07-20T09:14:03Z", "provider": "ollama", "embeddingModel": "nomic-embed-text" }
reindex starts a re-index of the tenant's ingested data and returns immediately:
202 {"status": "reindex_started"} — poll status to watch it land.
Errors¶
| Status | Body | When |
|---|---|---|
400 |
{"error": "question is required"} (chat), {"error": "query is required"} (search), {"error": "scope is required"} (schema) |
missing/blank required field |
401 |
plain text authentication required + WWW-Authenticate: Bearer header |
no/invalid token on /api/platform/* — note: not JSON |
401 |
{"error": "no tenant in security context"} |
analytic ask/schema call whose token resolves no usable tenant |
403 |
plain text missing role lm_integrator |
authenticated but not an integrator, on /api/platform/* |
403 |
{"error": "scope not granted"} |
a scope-bound user asked outside their granted set — the uniform refusal across the whole read surface |
429 |
{"error": "rate limit exceeded"} + Retry-After: <seconds> header |
bucket exhausted — honor Retry-After before retrying |
503 |
{"error": "...", "hint": "..."} |
the needed port (chat/embedding/analytic) is not on the classpath, or the LLM/embedding backend is unreachable — a deployment problem, not a caller problem |
Rate limits are per (bucket, tenant) in a fixed 60-second window. The chat and search
endpoints ride three buckets — user-chat, platform-chat, platform-search — each
configurable via lumnik.ratelimit.<bucket>.rpm (fallback lumnik.ratelimit.default.rpm,
code default 60; 0 disables). See rate limits.
Quickstart — one chat call with curl¶
# 1. Log in once (device flow against your OIDC issuer — see the deploy guide)
lm login
# 2. Grab the access token lm stored (single-context config; re-run after it expires)
TOKEN=$(awk '/^ *access:/ {print $2; exit}' ~/.lm/config.yaml)
# 3. Ask your data — the same call the PWA makes
curl -s https://your-hub.example.com/api/rag/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"question": "how many customers in Lyon?", "mode": "analytic", "scope": "clients"}'
The answer comes back with the SQL that ran — read it before you trust a zero.
See also¶
- Scopes — what a scope is, and the opt-in per-user binding
- Ask honesty — the five guards — the refusal contract behind analytic mode
- The métier PWA — the first consumer of this API
- Deploy — issuer,
lm login, pointinglmat the hub