Skip to documentation

Module references

Conduit

Conduit is the data module — capability area data, labelled Conduit (data) in the capability catalog. A workspace registers scoped, read-only connectors (PostgreSQL, MySQL, Snowflake) and a set of named queries: human-authored, single-statement SQL templates with typed parameters. Callers pick a query by slug and supply parameter values; they never supply SQL.

Every execution is checked against a read-only statement policy, capped by the connector's maxRows and timeoutMs, and recorded as an append-only data_calls row. The id of that row comes back as dataCallId — the anchor a [Dn] citation points at. Separately, a query can be bound to a graph entity or entity type, which is what lets other modules resolve "the current numbers for this thing" without anyone writing SQL at answer time.

One governed execution. The statement gate, the row/time caps, and the provenance write are not optional and not caller-configurable — they live in the executor, below the route.

Use it standalone

Conduit's public surface is three HTTP operations and one React component. Adopting it needs no documents, no knowledge graph, no embeddings, and no chat model.

  1. Issue a key with the data scopes

    data:read lists connectors and named queries. data:run executes one. They are independent — a read-only integration never needs data:run. On a delegated user key (eli_uk_) the owner must additionally still hold the matching capability: data:read for the reads, data:execute for the run. Workspace keys (eli_sk_) are scope-gated only.

  2. Install and import one entry point

    Headless: @eli-ai/client/data. React: @eli-ai/react/data plus @eli-ai/react/provider. Neither pulls in the aggregate client, the query types, or any other module's contracts.

  3. Run a query by slug

    Parameters are a flat object keyed by declared parameter name. Unknown keys are ignored and never reach SQL.

install (headless only)bash
npm install @eli-ai/client @eli-ai/contracts
the whole module, standalonets
import { createEliTransport } from "@eli-ai/client/core";
import { createDataClient } from "@eli-ai/client/data";
import type { DataRunResult } from "@eli-ai/contracts/data";

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

// data:read — what can I run?
const queries = await data.listQueries();
console.log(queries.items.map((q) => q.slug), queries.total);

// data:run — run one. Params are keyed by declared param name.
const result: DataRunResult = await data.runQuery("open-incidents-by-service", {
  params: { service: "checkout-api" },
});

console.log(result.columns, result.rowCount, result.truncated);
console.log(result.dataCallId); // the [Dn] provenance anchor

What this does NOT require

No kb:read / kb:write scope, no ingested documents, no entities or ontology, no agent definition or run, no embedding model, and no chat model. The run endpoint performs zero LLM calls — it is SQL execution behind a policy gate. Entity bindings are the one Conduit feature that genuinely needs another module; see How it composes.

Reading queries reveals their SQL

GET /api/v1/data/queries returns each query's full sqlTemplate. Treat data:read as "may read the SQL of every named query in the workspace," not merely "may see a list of titles." Connector credentials are never returned by any route — the ciphertext column is stripped and replaced with the boolean hasCredential.

React components

@eli-ai/react/data exports exactly one component, EliLiveData, plus its two label/prop types (EliLiveDataProps, EliLiveDataLabels). There is no separate connector-management or binding-editor component in the package.

EliLiveData

A single surface that loads the workspace's connectors and named queries on mount, renders the connectors as badges (the package's positive tone when enabled, neutral otherwise), and renders a form with a query select, a JSON textarea for parameters, and a submit button. Options for disabled queries render but are non-selectable, and each option is labelled title (falling back to slug) followed by its connector name when that connector is in the list. On success it renders a results table with the returned columns as headers, plus badges for rowCount, durationMs, and a "Truncated" badge when the row cap was hit. Cells that are objects or arrays are JSON-stringified; null and undefined render as an em dash.

The component issues one data:read call per list on mount and one data:run call per submit. The parameter textarea starts at {} and must parse to a JSON object: invalid JSON reports "Parameters are not valid JSON." and a JSON array or null reports "Parameters must be a JSON object." — both inline, before any request is made.

EliLiveData props

classNamestringAppended to the root element's classes, after the package's own eli-root and eli-surface classes.
titlestringSurface heading. Defaults to the labels value, "Live data".
descriptionstringSub-heading under the title. Defaults to the labels value describing running approved named queries.
labelsPartial<EliLiveDataLabels>Overrides for the ten built-in strings: title, description, connectors, query, parameters, run, empty, noQueries, results, duration. Merged over the defaults, so partial overrides are fine.
canExecutebooleanDefault true. When false the submit button is not rendered, so the form becomes a read-only browser of connectors and queries. This is a UI affordance, not an authorization check — the server scope is what actually gates execution.
initialQuerySlugstringDefault "". Pre-selects a query by slug on first render. It is initial state only; later prop changes do not re-select.
loadingLabelstringText shown while either list request is in flight. Defaults to "Loading live data…".
errorLabelstringText shown when the connector or query list fails, alongside a retry button that refetches both. Defaults to "Live data configuration could not be loaded.".
onError(error: unknown) => voidCalled for a failure of either list request or of the run mutation. Purely a notification hook; the component still renders its own error state.
onResult(result: DataRunResult) => voidCalled with the full run result on success — the same object the HTTP endpoint returns, including dataCallId. Use it to record the provenance anchor alongside whatever you render.
provider wiring + EliLiveDatatsx
import { createEliTransport } from "@eli-ai/client/core";
import { EliProvider } from "@eli-ai/react/provider";
import { EliLiveData } from "@eli-ai/react/data";
import "@eli-ai/react/styles.css"; // optional

// Point at a same-origin route that injects the Bearer key server-side.
// EliProvider deliberately has no API-key prop.
const transport = createEliTransport({ fetch: sameOriginProxyFetch });

export function LiveDataPanel({ workspaceId }: { workspaceId: string }) {
  return (
    <EliProvider transport={transport} workspaceId={workspaceId}>
      <EliLiveData
        initialQuerySlug="open-incidents-by-service"
        labels={{ title: "Operations", run: "Refresh" }}
        onResult={(result) => recordProvenance(result.dataCallId)}
        onError={(error) => reportToSentry(error)}
      />
    </EliProvider>
  );
}

Do not ship a workspace key to the browser

EliProvider accepts a transport (or an aggregate client whose transport it retains) and never a credential. Give it a fetch that calls your own same-origin endpoint, and attach the eli_sk_ key there. A data:run key in a bundle is a direct execute grant against production databases.

HTTP API

Three operations, all under /api/v1/data. Failures use the shared envelope { "error", "message" }; a body that fails validation adds an issues array. Because the apex 308-redirects, pass -L on the non-GET call so curl replays the POST body.

GET/api/v1/data/connectorsscope data:read · capability data:read

Every data connector in the workspace, ordered by name, credential-masked. Read-only — no connection is opened and nothing is executed.

Example requestbash
curl https://eli.ai/api/v1/data/connectors \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID"
Responsejson
{
  "items": [
    {
      "id": "dc_01H…",
      "workspaceId": "ws_01H…",
      "name": "Ops warehouse",
      "kind": "postgres",
      "config": { "host": "db.internal", "port": 5432, "database": "ops", "user": "eli_ro", "sslMode": "require" },
      "description": "Read replica of the incident store",
      "schemaSnapshot": null,
      "schemaDiscoveredAt": null,
      "maxRows": 200,
      "timeoutMs": 5000,
      "allowRawSql": false,
      "enabled": true,
      "lastCheckedAt": "2026-07-30T09:12:04.000Z",
      "lastStatus": "ok",
      "createdBy": "usr_01H…",
      "createdAt": "2026-06-02T11:00:00.000Z",
      "updatedAt": "2026-07-30T09:12:04.000Z",
      "hasCredential": true
    }
  ],
  "total": 1
}

config is the non-secret connection shape for that kind: Postgres and MySQL carry host · port · database · user (plus sslMode or ssl); Snowflake carries account · database · warehouse · user (plus optional schema and role). schemaSnapshot, when present, holds the discovered tables with their columns (name · dataType · nullable) and a nullable rowEstimate.

GET/api/v1/data/queriesscope data:read · capability data:read

The workspace's governed named queries, ordered by slug, optionally narrowed to one connector. Nothing is executed here.

Query parameters

connectorIdstringReturn only the queries owned by this connector. Omit for all of them.
Example requestbash
curl "https://eli.ai/api/v1/data/queries?connectorId=dc_01H…" \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID"
Responsejson
{
  "items": [
    {
      "id": "dq_01H…",
      "connectorId": "dc_01H…",
      "workspaceId": "ws_01H…",
      "slug": "open-incidents-by-service",
      "title": "Open incidents by service",
      "description": "Written for the model: counts open incidents for one service.",
      "sqlTemplate": "SELECT service, count(*) AS open_count FROM incidents WHERE service = $1 AND resolved_at IS NULL GROUP BY service",
      "params": [
        {
          "name": "service",
          "type": "string",
          "required": true,
          "description": "Service name"
        }
      ],
      "enabled": true,
      "createdBy": "usr_01H…",
      "createdAt": "2026-06-02T11:04:00.000Z",
      "updatedAt": "2026-07-11T16:40:00.000Z"
    }
  ],
  "total": 1
}

Each entry in params declares name, a type of string | number | boolean | date, and optionally required, default, and description. Order is meaningful: the executor binds params[0] to $1, and so on.

POST/api/v1/data/queries/{slug}/runBearer · data:run

Execute one governed named query. The path segment accepts the query's slug or its id. Read-only SQL, row and timeout caps, and a data_calls provenance row are enforced by the executor, below the route.

Request body

paramsRecord<string, unknown>Values keyed by declared parameter name. Optional — an omitted or empty body is treated as no params. Values are coerced to the declared type; a missing required param with no default is a 400. Unknown keys are ignored and never reach SQL.
Example requestbash
curl -L -X POST https://eli.ai/api/v1/data/queries/open-incidents-by-service/run \
  -H "Authorization: Bearer $ELI_API_KEY" \
  -H "X-Workspace-Id: $ELI_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{"params":{"service":"checkout-api"}}'
Responsejson
{
  "dataCallId": "dcall_01H…",
  "connectorId": "01KYCONNCONDUIT00000000000",
  "queryId": "01KYQRYCONDUIT000000000000",
  "columns": [
    "service",
    "open_count"
  ],
  "rows": [
    {
      "service": "checkout-api",
      "open_count": 3
    }
  ],
  "rowCount": 1,
  "durationMs": 41,
  "truncated": false
}

truncated is true when the connector's maxRows cap clipped the result — the rows you received are a prefix, not the whole answer. Data-layer failures map their code to a status:

  • 404 query-not-found — no query with that slug or id; connector-not-found— the query's connector row is gone.
  • 409 connector-disabled — emitted when either the query or its connector has enabled: false. The message names which one.
  • 400 invalid-params — a missing required param or a value that will not coerce to its declared type; not-read-only — the stored template failed the statement gate.
  • 502 execution-failed and 504 timeout — the external engine errored or exceeded timeoutMs.
  • 401 unauthorized; 403 insufficient_scope or insufficient_capability; 400 invalid_json / invalid_body from the shared gate.

Authoring is not part of the v1 key surface

There is no /api/v1 route that creates, edits, or deletes a connector, a named query, or an entity binding, and none that lists bindings. Those are session-authenticated workspace operations under /api/w/…, gated on the data:write capability and owner/admin. An API key can read the catalog and execute a query — nothing more.

Read-only is enforced statically, not by trusting the author

Before any execution the statement is stripped of literals and comments, then required to be a single statement beginning with SELECT or WITH, with no INSERT, UPDATE, DELETE, INTO, DDL, or session keywords anywhere in it — which is what rejects a writing CTE and SELECT … INTO. Connectors also expose an allowRawSql opt-in for exploratory SQL through other surfaces; it is off by default and is not reachable through the three routes above.

Data elements

Four tables. All four are workspace-scoped and carry a row-level-security policy keyed on workspace_id for every operation, so a query outside the workspace transaction context returns nothing — the API's scope check and RLS are two independent gates.

TableConduitColumns that matter to a consumerWhat one row means
data_connectorsowns · RLSid · workspace_id · name · kind · config · credential_enc · max_rows · timeout_ms · allow_raw_sql · enabled · schema_snapshot · last_statusOne scoped external source. credential_enc is AES-256-GCM at rest, decrypted only inside the executor at call time, and never leaves the server — the API returns hasCredential instead. max_rows/timeout_ms are the caps applied to every call. Unique on (workspace_id, name).
data_queriesowns · RLSid · workspace_id · connector_id · slug · title · description · sql_template · params · enabledOne named query. sql_template uses positional $1..$n placeholders only; params is the typed spec array those placeholders bind to, in order. description is written for a model to read. Unique on (workspace_id, slug).
entity_bindingsowns · RLS · not on /api/v1id · workspace_id · entity_id · entity_type · query_id · label · param_map · enabledAttaches a named query to one entity or to a whole entity type — exactly one of entity_id / entity_type is set. param_map says where each query param comes from: entity_name, a const value, or model (supplied at call time). Read and written by other modules; no v1 route exposes it.
data_callswrites · RLS · append-onlyid · workspace_id · connector_id · query_id · binding_id · run_id · conversation_id · sql_text · params_used · row_count · duration_ms · status · error · executed_by · executed_atOne execution attempt. status is ok | error | timeout | blocked — failures and policy rejections are recorded too, not just successes. sql_text is the exact statement executed. The id is the dataCallId returned by the run endpoint and the target of a [Dn] citation.

Provenance is written even when the call fails

A timeout, a connector error, or a policy rejection still produces a data_calls row with the corresponding status. That means the audit trail answers "what did we try to ask, and what happened" — not only "what succeeded." The row is append-only; there is no update path.

How it composes

Conduit runs entirely on its own for the slug-and-params use case. What the other modules add is binding — the ability to go from a thing in the graph to the live numbers about it without a caller knowing which query to run.

With Atlas — bindings become available

entity_bindings resolves against entity rows: a binding matches either the entity's id or its type, and a lookup for one entity returns its direct bindings plus the ones inherited from its type. Without Atlas there are no entities to bind to, so the binding table stays empty and the param_map source entity_name has nothing to resolve. Everything in the three v1 routes still works unchanged — those never touch bindings.

With Lens — live rows join the cited answer

POST /api/v1/query takes an includeData boolean. When true, the pipeline resolves bindings for the entities it detected in the question, executes those whose params fully resolve without a model source, and returns them as dataCalls — each entry carrying id · dataCallId · connectorName · queryTitle · rowCount · executedAt. It is capped at three data calls per answer, a per-binding failure never blocks the document-grounded answer, and that route gates on agents:run and needs a configured chat model — both things Conduit alone does not. See Lens.

With Warrant — data evidence can overturn an abstain

In the same pipeline, a retrieval-floor verdict of abstain_with_pointers is overridden to proceed when live data actually ran, and the override is recorded on the decision. That is a Warrant policy behaviour reacting to Conduit evidence; it does not exist if you use Conduit by itself.

With Ports — two MCP tools

The MCP server exposes kb_data_discover (accepts data:read or data:run) to list the bindings that apply to a named entity along with the params the caller still has to supply, and kb_data_lookup (requires data:run) to execute one, selected by bindingId or bindingLabel. Omitting both works only when exactly one binding applies. Both mirror the REST scope map rather than bypassing it, and the lookup returns the same eight fields as the run endpoint. See Ports.

With Lineage — the [Dn] trail

data_calls is the provenance substrate a Lineage trace follows for a live figure, the way a document chunk backs an [Sn] citation. Conduit writes those rows unconditionally, so the trail exists whether or not you have adopted the module that reads it.

The honest standalone boundary

Slug-and-params execution, the connector and query catalog, the read-only gate, the caps, and the provenance row are all Conduit. Anything phrased as "ask a question and get live numbers back" is Conduit plus Atlas plus Lens — it needs entities to bind to and a chat model to answer with.

Extending Conduit

A plugin can add a live-data engine with dataAdapters — resolved synchronously and shape-checked before the executor will use it, with the read-only SQL gate, parameter coercion and row caps staying host-owned. Plugins can also execute governed queries through the facade: ctx.data.conduit.runNamedQuery(slug, params) requires data:execute and appends the same data_calls provenance row any other caller would, so a plugin-driven figure is as citable as a human-driven one. Conduit emits data.call.completed and data.query.saved. See the extension model for the contract and the trust boundary, and Build a plugin for a worked example.