Skip to documentation

Module references

Lens — retrieval & the answer policy

Lens is the module that turns a question into an answer you can check. It is one of eight capability modules and it is adoptable on its own: two React components, two primary HTTP endpoints, and three read-only observability endpoints.

What it does

Lens searches a workspace's documents and answers questions from them. Every answer comes back with its evidence attached: the atomic claims the model made, the [Sn] sources each claim cites, a retrieval-confidence summary, and an answer-policy verdict. The policy is the part that makes the output trustworthy — before generating it can decide to ask a clarifying question or abstain with pointers to the closest documents, and after generating it attaches a calibrated confidence and caveats rather than rewriting the model's prose.

The raw retrieval channel is also exposed on its own. GET /api/v1/search ranks passages with no model invoked at all, which makes it usable in a workspace that has no chat model configured.

Two entry points. /query runs retrieval, the policy pre-gate, generation, and the post-gate, recording every gate decision. /search stops after retrieval and never calls a model.

Use it standalone

Adopting Lens alone means one package entry, one API scope, and a workspace that has documents in it. Nothing else in the eight-module surface has to be wired up.

  1. Issue a key with the right scope

    POST /api/v1/query requires the agents:run scope — the call invokes the workspace's chat model, so it gates like an agent run, and that scope is also what licenses the per-call policyMode override. GET /api/v1/search requires kb:read. A key with both covers the whole module. The three read-only surfaces below accept kb:read or the legacy runs:read.

  2. Install only what this module needs

    @eli-ai/react/query imports @eli-ai/client/query, @eli-ai/contracts/query, and the shared provider/hooks. No other capability subpath is pulled in.

    installbash
    npm install @eli-ai/react @eli-ai/client react
  3. Wire the provider and drop in the component

    EliProvider has no API-key prop by design. It takes an already-built transport, so credential handling stays outside the render tree — in the browser, point the transport's fetch at a same-origin route of yours that injects the Bearer key server-side.

the whole module, in a React apptsx
import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliQuery } from "@eli-ai/react/query";
import "@eli-ai/react/styles.css";

// sameOriginProxyFetch hits your own route, which adds the Bearer key.
const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function AskEli({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliQuery
        initialMode="ask"
        searchLimit={10}
        onAnswer={(answer) => {
          // answer.policy is null only when the effective policy mode is "off".
          console.log(answer.policy?.verdict, answer.sources.length);
        }}
      />
    </EliProvider>
  );
}
the same thing headless, from a serverts
import { createEliTransport } from "@eli-ai/client/core";
import { createQueryClient } from "@eli-ai/client/query";
import type { StructuredAnswer } from "@eli-ai/contracts/query";

const query = createQueryClient(
  createEliTransport({
    baseUrl: "https://eli.ai",
    apiKey: () => process.env.ELI_API_KEY!,
    workspaceId: process.env.ELI_WORKSPACE_ID,
  }),
);

const answer: StructuredAnswer = await query.ask({
  question: "How do we requeue stuck orders?",
  maxSources: 8,
  policyMode: "enforce",
});

if (answer.policy?.verdict === "abstain") {
  // Lens declined rather than guessing — answer.policy.reasons says why.
}

What Lens does NOT require

No other module. With includeData left at its default (false) Lens never touches a data connector. Entity detection and graph fusion each degrade to a no-op when the graph is empty — detection failures are swallowed and retrieval simply proceeds without them. No golden set, connector, report, or agent is involved.

No embeddings provider. The vector channel degrades to absence on any failure (unconfigured provider, retired space, network error); full-text alone still answers.

No reranker key. The reranker setting resolves to a no-op passthrough when no provider key is present.

No Next.js. The React package uses ordinary React, URLs, callbacks, and the injected transport.

It does require documents in the workspace, and POST /api/v1/query requires a configured chat model — without one it returns 409 no-model-configured. GET /api/v1/search has no model dependency at all.

React components

@eli-ai/react/query exports exactly two components. Both render into the package's scoped eli-* classes and set eli-root on the outer element, so the optional @eli-ai/react/styles.css export styles them without leaking into your own CSS. Both are client components and must be rendered inside an EliProvider.

EliQuery

A single surface with two tabs. In search mode it renders a list of ranked passages — document title, path, snippet, a percentage badge and a relevance meter, each row a button that fires onDocumentSelect. In ask mode it renders the answer text, a badge for the policy verdict, a verified/total grounded badge when groundedness was computed, and the list of sources. Errors render an inline retry button. A search that matched nothing renders an empty-state card; ask mode renders a placeholder card until the first answer arrives.

EliQuery props

initialMode"search" | "ask"Which tab is active on mount. Default "search". Coerced to "search" when allowAsk is false.
initialValuestringPrefills the input. Default "". The user can still edit it.
allowAskbooleanDefault true. When false the tab strip is not rendered and the component is search-only.
searchLimitnumberPassed as the limit query parameter on search calls. Default 10. Does not affect ask mode.
labelsPartial<EliQueryLabels>Overrides for the twelve UI strings: title, description, searchTab, askTab, searchPlaceholder, askPlaceholder, submitSearch, submitAsk, emptySearch, emptyAnswer, sources, retry.
titlestringSurface heading. Falls back to labels.title.
descriptionstringSub-heading under the title. Falls back to labels.description.
classNamestringAppended to the root element's class list, after eli-root.
loadingLabelstringSubmit-button text while a request is in flight. Default "Working…".
errorLabelstringMessage shown in the error state. Default "The query could not be completed."
onSearch(response: SearchResponse) => voidFired with the full search response on success.
onAnswer(answer: StructuredAnswer) => voidFired with the full structured answer on success — claims, sources, policy, and all.
onDocumentSelect(documentId: string) => voidFired with a docId when a search result is clicked, or when the Open button on a source row is clicked. The Open button is only rendered when this prop is supplied.
onError(error: unknown) => voidFired on a failed search or ask, in addition to the provider-level onError.

EliSearch

A convenience wrapper that renders EliQuery with allowAsk={false} and initialMode="search" pinned. It accepts every EliQuery prop except those two, so it is the component to reach for when you want retrieval with no model spend and no way for a user to trigger one.

both components, with provider wiringtsx
import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliQuery, EliSearch } from "@eli-ai/react/query";
import "@eli-ai/react/styles.css";

const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function KnowledgePanel({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider
      transport={transport}
      workspaceId={workspaceId}
      onError={(error, context) => reportToSentry(error, { context })}
    >
      {/* Search + ask, with a renamed ask tab. */}
      <EliQuery
        initialMode="ask"
        initialValue="How do we requeue stuck orders?"
        labels={{ askTab: "Ask the knowledge base", sources: "Cited documents" }}
        onDocumentSelect={(docId) => router.push("/documents/" + docId)}
      />

      {/* Retrieval only — no tab strip, no model call is reachable from here. */}
      <EliSearch searchLimit={25} title="Find a passage" />
    </EliProvider>
  );
}

Props the components deliberately do not expose

EliQuery calls ask({ question }) and search({ q, limit }) — nothing more. That means includeData, maxSources and policyMode on the query body, and rerank on search, are not reachable through these components. Use the headless createQueryClient with useEliMutation from @eli-ai/react/hooks when you need them, or set the workspace-level retrieval and policy defaults.

headless hook when you need the full request bodytsx
import { createQueryClient } from "@eli-ai/client/query";
import type { StructuredAnswer } from "@eli-ai/contracts/query";
import { useEliMutation } from "@eli-ai/react/hooks";

export function GroundedAsk() {
  const ask = useEliMutation<StructuredAnswer, string>({
    context: "grounded-ask",
    mutation: ({ transport, workspaceId }, question, signal) =>
      createQueryClient(transport).ask(
        { question, includeData: true, maxSources: 12, policyMode: "enforce" },
        { signal, ...(workspaceId ? { workspaceId } : {}) },
      ),
  });

  return (
    <>
      <button onClick={() => void ask.mutate("What changed this quarter?")} disabled={ask.isPending}>
        Ask
      </button>
      {ask.data?.policy?.verdict === "clarify" && <p>{ask.data.answer}</p>}
    </>
  );
}

HTTP API

Five routes. The first two are the module; the last three are its read-only observability surface. Every request carries Authorization: Bearer …, plus X-Workspace-Id when the key is a multi-workspace user key. The apex domain 308-redirects, so non-GET curl examples pass -L.

POST/api/v1/queryscope: agents:run

Run the full Lens pipeline — retrieval, entity detection, the policy pre-gate, one chat-model generation, a best-effort groundedness check, and the post-gate — and return a single structured answer. User keys additionally need the agents:execute capability. Returns 409 with the error code no-model-configured when the workspace has no chat model configured.

Request body

questionrequiredstring (1–4000)The question to answer. Rejected with 400 invalid_body when empty or over 4000 characters.
includeDatabooleanDefault false. When true, executes the data bindings of detected entities (at most 3 governed read-only calls) and grounds the answer in their rows as [Dn] citations. Requires the Conduit module to be configured; without it the doc-grounded answer still ships.
maxSourcesinteger (1–20)Cap on retrieved document chunks. Default 8.
policyMode"off" | "shadow" | "enforce"Answer-policy override for this one call. Omitted → the workspace policy.mode applies. enforce lets the pre-gate short-circuit into clarify/abstain; shadow computes and records decisions without altering the answer; off skips policy entirely and returns policy: null.
Example requestbash
curl -L -X POST https://eli.ai/api/v1/query \
  -H "Authorization: Bearer eli_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "question": "How do we requeue stuck orders?",
    "maxSources": 8,
    "policyMode": "enforce"
  }'
Responsejson
{
  "answer": "Stuck orders older than 24 hours are requeued from the fulfillment console [S1].",
  "claims": [
    {
      "text": "Stuck orders older than 24 hours are requeued from the fulfillment console",
      "citations": ["S1"]
    }
  ],
  "sources": [
    {
      "id": "S1",
      "docId": "01KY10AAAAAAAAAAAAAAAAAAAA",
      "docTitle": "Order Platform incident runbook",
      "docPath": "ops/order-platform-runbook.md",
      "chunkId": "01KY10BBBBBBBBBBBBBBBBBBBB",
      "snippet": "When orders remain in STUCK status for more than 24 hours…"
    }
  ],
  "dataCalls": [],
  "entities": [
    {
      "id": "01KY10DDDDDDDDDDDDDDDDDDDD",
      "name": "Order Platform",
      "type": "system",
      "pagerank": 0.041,
      "authority": "curated",
      "lifecycleStatus": "published"
    }
  ],
  "retrieval": {
    "topScore": 0.0324,
    "meanScore": 0.0191,
    "chunkCount": 8,
    "channels": { "fts": 6, "vector": 5 }
  },
  "retrievalDebug": {
    "understanding": {
      "rewrite": "requeue stuck orders fulfillment console",
      "subQueries": [],
      "filters": {},
      "isAmbiguous": false
    },
    "stages": {
      "candidatePool": 40,
      "graphAdded": 6,
      "reranked": true,
      "assembledTokens": 3820
    },
    "rerankerKind": "voyage"
  },
  "groundedness": { "verified": 1, "total": 1 },
  "policy": {
    "mode": "enforce",
    "verdict": "answer",
    "confidence": 0.78,
    "calibrationVersion": "bootstrap-v1",
    "reasons": ["retrieval_ok"],
    "caveats": [],
    "conflicts": []
  },
  "model": { "chat": "anthropic/claude-sonnet-4", "judge": "anthropic/claude-haiku-4" },
  "usage": { "inputTokens": 4210, "outputTokens": 168, "costUsd": 0.0142 },
  "generatedAt": "2026-07-21T09:14:03.412Z"
}

Reading the answer

Four fields on the response are nullable and mean something specific when they are null:

  • retrieval is null when nothing was retrieved at all. Otherwise it is the fused summary: topScore, meanScore, chunkCount, and channels, a Record<string, number> keyed by channel name (fts, vector).
  • groundedness is null when no judge model resolved or the check failed. It is a two-field shape and nothing more: { verified, total }, both integers — counts of claims the judge could verify against the evidence.
  • policy is null when the effective mode is off, or when a gate failed (a policy fault never fails the answer). The verdict is one of six values — proceed, clarify, abstain_with_pointers from the pre-gate; answer, answer_with_caveat, abstain from the post-gate. On a pre-gate short-circuit the block also carries clarify and pointers.
  • usage is null only on a served cache hit. A pre-gate short-circuit reports zero tokens rather than null, because no model ran.

retrievalDebug is optional and purely informational — it never changes the sources or the verdict. See Retrieval pipeline for what each stage count means, and Answer policy for how confidence is computed.

GET/api/v1/searchscope: kb:read

Hybrid retrieval — full-text and pgvector, reciprocally fused — over the workspace's chunks. No model is invoked. User keys additionally need the documents:read capability. An empty or absent q returns an empty result set rather than an error.

Query parameters

qstringThe search query. Empty or absent → results: [] and total: 0.
limitinteger (1–50)Max chunks to return. Default 10. Out of range returns 400 invalid_params.
rerankbooleanOnly the exact string 'true' opts in. Runs a cross-encoder rerank over a wider candidate pool with the workspace's configured reranker, adds rerankScore to each result, and MMR-deduplicates. Query understanding and graph fusion stay off on this route. Degrades to raw retrieval when no reranker is available.
Example requestbash
curl -L "https://eli.ai/api/v1/search?q=requeue%20stuck%20orders&limit=10&rerank=true" \
  -H "Authorization: Bearer eli_sk_..."
Responsejson
{
  "query": "requeue stuck orders",
  "results": [
    {
      "chunkId": "01KY10BBBBBBBBBBBBBBBBBBBB",
      "docId": "01KY10AAAAAAAAAAAAAAAAAAAA",
      "docTitle": "Order Platform incident runbook",
      "docPath": "ops/order-platform-runbook.md",
      "headingPath": "Runbook > Stuck orders",
      "snippet": "When orders remain in STUCK status for more than 24 hours, requeue them…",
      "score": 0.031,
      "channels": ["fts", "vector"],
      "rerankScore": 0.94
    }
  ],
  "total": 1
}

Two details the shape hides

snippet is the chunk content truncated to 240 characters with an ellipsis appended — it is not a highlighted extract. rerankScore is omitted entirely (not null) when reranking did not run, which is why the contract types it as optional.

GET/api/v1/policy/decisionsscope: kb:read or runs:read

The append-only answer-policy decision log, newest first. Read-only — rows are written by the answer paths, never through this API. User keys additionally need the governance:read capability.

Query parameters

surface"chat" | "structured" | "agent"Filter by the surface that made the decision. /api/v1/query records 'structured'. Any other value returns 400 invalid-params.
verdictPolicyVerdictOne of proceed, clarify, abstain_with_pointers, answer, answer_with_caveat, abstain. Any other value returns 400 invalid-params.
limitinteger ≥ 1Page size. Default 50, clamped to a maximum of 200.
offsetinteger ≥ 0Rows to skip. Default 0.
Example requestbash
curl "https://eli.ai/api/v1/policy/decisions?surface=structured&verdict=abstain&limit=20" \
  -H "Authorization: Bearer eli_sk_..."
Responsejson
{
  "items": [
    {
      "id": "01KY10EEEEEEEEEEEEEEEEEEEE",
      "surface": "structured",
      "conversationId": null,
      "runId": null,
      "question": "What is our SOC 2 audit window?",
      "stage": "post",
      "verdict": "abstain",
      "mode": "enforce",
      "confidence": 0.21,
      "calibrationVersion": "bootstrap-v1",
      "features": { "topScore": 0.007, "chunkCount": 2, "onlyUnverified": true },
      "reasons": ["low_confidence"],
      "caveats": [{ "kind": "no_verified_source", "detail": "No cited document is verified." }],
      "createdAt": "2026-07-21T09:14:03.412Z"
    }
  ],
  "total": 143,
  "limit": 20,
  "offset": 0
}
GET/api/v1/policy/statsscope: kb:read or runs:read

Decision counts by verdict and by surface over the last 30 days — the rollout dial for moving a workspace from shadow to enforce. Same gate as the decision log.

Example requestbash
curl "https://eli.ai/api/v1/policy/stats" \
  -H "Authorization: Bearer eli_sk_..."
Responsejson
{
  "since": "2026-06-21T09:14:03.412Z",
  "total": 143,
  "byVerdict": { "proceed": 96, "answer": 88, "answer_with_caveat": 21, "abstain": 12 },
  "bySurface": { "structured": 120, "chat": 23 }
}
GET/api/v1/cache/statsscope: kb:read or runs:read

Health of the semantic answer cache that sits in front of the /query path. Read-only; entries are written by the answer path and invalidated by document changes. User keys additionally need the documents:read capability.

Example requestbash
curl "https://eli.ai/api/v1/cache/stats" \
  -H "Authorization: Bearer eli_sk_..."
Responsejson
{
  "mode": "shadow",
  "active": 412,
  "invalidated": 37,
  "expired": 9,
  "total": 458,
  "withEmbedding": 412,
  "totalHits": 190,
  "hitRate": 0.293,
  "lastHitAt": "2026-07-21T09:12:44.008Z"
}

hitRate is an estimate, and the client for these three lives elsewhere

hitRate is computed as totalHits / (totalHits + total) — each stored entry stands in for at least one prior miss. It is null when there is no data.

These three read surfaces are not on @eli-ai/client/query. They live on createQualityClient from @eli-ai/client/quality as listPolicyDecisions, getPolicyStats and getCacheStats, and the React surface for them is EliQuality in @eli-ai/react/quality — a component shared with Crucible, whose eval tabs you get whether or not you want them.

Data elements

Every table below is tenant-scoped: each carries a workspace_id column and a row-level-security policy that constrains both reads and writes to the workspace of the surrounding transaction. Lens never selects across workspaces, and a key resolved for workspace A cannot read workspace B's rows even through a crafted id.

TableLensColumns that matterWhat a row means
documentsreadid · path · title · verification · acl_visibility · acl_principals · deleted_atOne source document. Lens filters on deleted_at and the two ACL columns before ranking, and reads verification into the policy's authority features.
chunksreadid · doc_id · ord · heading_path · content · content_hash · token_countThe retrievable unit. The full-text channel searches a generated tsvector column on this table; content becomes the [Sn] snippet.
chunk_embeddingsreadchunk_id · space_id · embeddingThe pgvector cosine channel. Absent or unconfigured, retrieval degrades to full-text only rather than failing.
embedding_spacesreadid · provider · model_id · dim · statusWhich embedding model the vector channel queries. At most one row per workspace has status 'active'.
entitiesreadid · authority · lifecycle_statusGovernance labels for entities detected in the question. Rows in draft or pending_review are dropped from the answer; a certified entity feeds the policy's hasCertifiedConcept feature.
policy_calibrationsreadversion · coeffs · activeThe fitted confidence coefficients the gates score with. At most one active row per workspace; a missing or unreadable row falls back to the built-in bootstrap defaults.
policy_decisionswrite · readid · surface · question · stage · verdict · mode · confidence · calibration_version · features · reasons · caveats · created_atOne append-only row per gate decision — a pre row and, when generation ran, a post row. surface is 'structured' for /api/v1/query. This is what GET /policy/decisions and /policy/stats read.
evidence_conflictswriteclaim_hash · claim_text · chunk_a · chunk_b · doc_a · doc_b · kind · stance_a · stance_b · detected_byA detected disagreement between two cited chunks, deduped on the canonically ordered chunk pair. Written only when conflict detection is on and a judge model resolves.
semantic_cache_entrieswrite · readexact_key · scope_key · question · embedding · answer · model_id · prompt_version · hit_count · expires_at · invalidated_atA replayable StructuredAnswer minus usage, keyed by question + model + prompt version + effective config. Off by default; never written for includeData answers.
semantic_cache_dependencieswriteentry_id · doc_id · doc_content_hashThe [Sn] documents a cached answer depends on, with the content hash at store time — the lookup that invalidates an entry when a source document changes.

The two rows you will actually query

policy_decisions is the audit artifact: it answers "why did the system decline this question?" with the exact feature snapshot the gate scored, and it is written in shadow mode too — which is what lets you measure a policy before you let it change any answer. semantic_cache_entries is the cost artifact: it is off by default, and answers with includeData: true are never cached because their [Dn] rows are non-deterministic.

How it composes

Lens is the module the other seven point at. It works alone, and each neighbor makes it measurably better at something specific.

What it gains

  • Intake— the honest dependency. Lens ranks and cites documents; something has to put them there. Intake's connectors also populate acl_visibility and acl_principals, which is what lets restricted documents be retrievable by the right people and invisible to everyone else. Without Intake you can still write documents through any other path — Lens does not care how they arrived.
  • Atlas — the entity graph turns on two things. Graph fusion pulls in chunks that are structurally related but textually distant, reported as retrievalDebug.stages.graphAdded. And question-entity detection populates the entities array on the answer. With no graph, both degrade silently to nothing and retrieval proceeds on text alone.
  • Warrant— governance is what the policy's confidence is partly made of. Document verification feeds the authority features (an answer resting only on unverified sources is capped), entity lifecycle_status keeps draft and pending-review concepts out of answers entirely, and Warrant's calibration surface writes the policy_calibrations row the gates read. Without it every document is unverified and the gates score against bootstrap coefficients — the policy still runs, just less sharply.
  • Conduit — genuinely required for one feature. includeData: true executes the data bindings of detected entities and adds [Dn] citations to the answer. With no connectors configured the call does not fail: the data layer is caught, dataCalls comes back empty, and the doc-grounded answer ships. Live data also has one policy consequence — an abstain_with_pointers pre-verdict is overridden to proceed when data actually ran, because rows can ground an answer that documents could not.
  • Lineage — Lens hands you docId and chunkId on every source. Lineage is what turns those ids into a provenance trail and a blast radius. Without it the citation is still a real, resolvable document reference.
  • Crucible — the only way to know whether a retrieval or policy change helped. Because the two non-deterministic stages both have deterministic-off paths, an eval run can pin Lens to a fully reproducible configuration. Crucible also owns /api/v1/evals and /api/v1/qrels, which are not part of Lens.
  • Ports — the same pipeline is reachable over MCP as the eli_query tool, so an external agent gets identical grounding and the identical policy verdict without going through HTTP.

What still works with none of them

A workspace with documents and a chat model gets the complete Lens contract: fused full-text retrieval, cited claims, a retrieval-confidence summary, groundedness when a judge model is configured, and every policy verdict including clarify and abstain. The parts that need neighbors — graph fusion, live data, verification-weighted confidence, fitted calibration — are the parts that were designed to be absent, and each one is a no-op rather than an error when it is.

Going deeper

Retrieval pipeline explains the five stages and how each degrades. Answer policy covers the verdict router and the confidence model. Tune retrieval quality and Tune the answer policy are the operator loops. Structured query is the endpoint reference, and JavaScript & React SDK covers the transport, workspace selection, and error types shared by every module.

Extending Lens

Two contribution points sit inside the pipeline: rerankers adds a reranker kind next to Voyage, Cohere and the self-hosted ONNX model (the host owns the reported kind, so a plugin cannot impersonate voyage in the trace), and candidateChannelsmerges an extra recall channel into the candidate pool under an 800 ms budget. A contributed channel is ACL-filtered downstream like every other channel, so the worst it can do is promote documents the caller could already read. Lens emits answer.produced and cache.invalidated. See the extension model for the contract and the trust boundary, and Build a plugin for a worked example.