Skip to documentation

Conduit

Data connectors & domain components

The data layer starts with a connector — a scoped, read-only external data source — and is consumed through domain components: named, parameterized queries that humans author and the platform governs. Together they are the only path from a question to an external system. Connectors come in three kinds — postgres, mysql, and snowflake — behind one adapter contract, so governance, provenance, and citations are identical regardless of engine. Concepts first: Semantic ↔ data layer.

Connectors

A connector separates what is secret from what is not:

Connector fields

namerequiredstringUnique per workspace, e.g. "ops-postgres".
kindrequired"postgres" | "mysql" | "snowflake"Connector kind — selects the engine adapter. Fixed after creation.
configrequiredjsonNon-secret connection config; the shape is per-kind (see Engines below).
credentialrequiredstringThe password/DSN secret. Encrypted at write (AES-256-GCM, enc:v1 envelope); decrypted only at execution; never returned by any API.
maxRowsnumberHard row cap enforced on every call. Default 200. Results past the cap are cut and flagged truncated.
timeoutMsnumberStatement timeout per call. Default 5000. A slow query becomes a timeout data_call, not a hung answer.
allowRawSqlbooleanEscape hatch for exploratory read-only SQL through the tool layer. Off by default; owner opt-in per connector.
enabledbooleanDisabled connectors refuse execution (connector-disabled) but keep their configuration and history.

Credentials are write-only

Like SSO client secrets and webhook signing secrets, connector credentials follow the masking pattern: the API accepts a secret on create/update, stores only the enc:v1 ciphertext, and every read returns a masked placeholder. Decryption happens exclusively inside the executor at call time, with short-lived connections per call. Use a read-only database role for the connector user — read-only is enforced in the platform, but defense in depth is free.

Test connection. Each connector has a test action that opens a connection, runs a trivial probe, and stamps lastCheckedAt / lastStatus — so the Data page shows at a glance which sources are healthy before an agent ever depends on them.

Engines

Each kind maps to one engine adapter — the only code that touches a driver SDK. Adapters share one interface (test connection, discover schema, execute under caps), so everything downstream of the connector is engine-agnostic. What differs per engine is the non-secret config shape and how read-only is enforced at the session level.

postgres config

hostrequiredstringHostname of the Postgres server or pooler.
portrequirednumberUsually 5432.
databaserequiredstringDatabase name.
userrequiredstringRole to connect as — use a read-only role.
sslModestringdisable | no-verify | require — maps to pg ssl options.

mysql config

hostrequiredstringHostname of the MySQL server.
portrequirednumberUsually 3306.
databaserequiredstringSchema (database) name.
userrequiredstringAccount to connect as — use a read-only account.
sslbooleanEnable TLS for the connection.

snowflake config

accountrequiredstringAccount identifier, e.g. "xy12345.us-east-1".
databaserequiredstringDatabase name.
schemastringNamespace within the database. Default PUBLIC.
warehouserequiredstringVirtual warehouse to run statements on.
userrequiredstringUser to connect as.
rolestringRole to assume — use a read-only role.

Read-only enforcement differs by engine — know your floor. Every execution on every engine passes the shared statement gate first: single-statement SELECT/WITH only, validated at save time and again at execution time. On top of that:

  • Postgres and MySQL additionally set the session read-only mode on every short-lived connection before the statement runs — a second, engine-level wall: even a statement that slipped the gate could not write.
  • Snowflakehas no session read-only mode, so enforcement there is the statement gate plus your role grants. This is exactly the case where the "read-only role" recommendation stops being defense in depth and becomes the second wall itself — grant the connector role SELECT only.

One template dialect. Domain component templates are always written with positional $1..$nplaceholders, whatever the engine. The adapter rewrites them to the driver's native style ($n for Postgres, ? binds for MySQL and Snowflake) with a pure, unit-tested rewriter — values always travel as bound parameters, never interpolated into SQL text.

Schema discovery

Authoring a good query requires knowing what is there. Each connector has a discoveraction that reads the engine's information_schema (through the same read-only, capped execution path) and stores a schema snapshot on the connector: every visible table with its schema, columns (name, engine-native type, nullability), and a cheap row-count estimate where the engine offers one. The snapshot is stamped discoveredAt, so you always know how fresh your view of the source is — re-run discovery any time the upstream schema changes.

a schema snapshot (stored on the connector)json
{
  "engine": "mysql",
  "database": "northwind_mysql",
  "discoveredAt": "2026-07-19T14:02:11.000Z",
  "tables": [
    {
      "schema": "northwind_mysql",
      "name": "shipments",
      "rowEstimate": 120,
      "columns": [
        { "name": "id", "dataType": "bigint", "nullable": false },
        { "name": "order_ref", "dataType": "varchar(32)", "nullable": false },
        { "name": "carrier", "dataType": "varchar(64)", "nullable": true },
        { "name": "status", "dataType": "enum('pending','in_transit','delivered','lost')", "nullable": false },
        { "name": "cost_usd", "dataType": "decimal(10,2)", "nullable": true },
        { "name": "shipped_at", "dataType": "datetime", "nullable": true }
      ]
    }
  ]
}

The snapshot powers three things: the schema browser on the Data page, snapshot-validated table previews, and the context handed to LLM query generation — all below.

Safe table previews

From the schema browser you can preview any discovered table — a bounded SELECTof its first rows. Previews never build SQL from free text: the table and column identifiers must exist in the stored snapshot, are quoted with the engine's own identifier quoting, and everything else about the statement is fixed. A table name that is not in the snapshot is refused, which closes the identifier-injection path entirely. Previews run under the connector's maxRows and timeoutMs and write a data_calls provenance row like any other execution.

Domain components (named queries)

A domain component is a governed query: a human writes the SQL once, declares its parameters, and describes it for the model. Consumers — the kb_data tool, the structured query API, MCP clients — supply validated parameters only.

Domain component fields

slugrequiredstringStable identifier, unique per workspace, e.g. "order-backlog". This is how tools and bindings reference it.
titlerequiredstringHuman-readable name shown in the UI.
descriptionstringShown to the model during discovery — write it for an LLM: what the query answers and when to use it.
sqlTemplaterequiredstringSingle-statement SELECT or WITH using positional parameters $1..$n only (any engine — the adapter translates). Validated at save time and again at execution time.
paramsDataParamSpec[]One spec per $n: { name, type: string | number | boolean | date, required?, default?, description? }. Calls are validated against these before any SQL runs.
enabledbooleanDisabled components are hidden from discovery and refuse execution.
a domain componentjson
{
  "slug": "customer-churn-risk",
  "title": "Customer churn risk",
  "description": "Segment, region, annual value, and churn risk for a customer by name. Use when asked about a specific customer's health or renewal risk.",
  "sqlTemplate": "SELECT name, segment, region, annual_value_usd, churn_risk FROM customers WHERE name ILIKE '%' || $1 || '%' LIMIT 5",
  "params": [
    { "name": "customer_name", "type": "string", "required": true, "description": "Customer display name (partial match)" }
  ]
}

Save-time SQL validation. A component that is not a single SELECT/WITH statement is rejected when you save it — not discovered at 2am when an agent calls it. The same check re-runs at execution time (not-read-only), so a template can never drift into a write.

LLM query generation

Writing a component by hand requires knowing SQL and the source schema. Generation removes the first requirement without loosening any rule: describe what you want in plain language, and the model drafts the SQL from the connector's schema snapshot — real tables, real columns, real types, not guesses. The draft lands in the same editor a hand-written component uses, for a human to review, edit, test-run, and only then save.

Discover the schema, browse it, let the model draft SQL against the snapshot, review and edit as a human, save as a governed domain component — then kb_data, the API, and MCP consume it like any other.

Generated SQL never auto-saves

The model's draft is a proposal, nothing more. It is never executed against the source and never saved as a component without a human clicking save — and the save runs the exact same read-only validation every hand-written template passes (single-statement SELECT/WITH, $1..$n params, typed param specs), with the same re-check at execution time. Generation changes who types the first draft; it changes nothing about governance.

Once saved, a generated component is indistinguishable from a hand-written one: it is discoverable by kb_data, callable through POST /api/v1/query and the MCP server, and bindable to entities.

Governance at execution

Every execution — from any surface, on any engine — passes the same gauntlet:

  1. Connector and component must exist and be enabled.
  2. Params are validated against the DataParamSpec list — types coerced, required params present, defaults applied (invalid-params otherwise).
  3. Single-statement SELECT/WITH re-verified (not-read-only).
  4. Credentials decrypt, a short-lived connection opens (session read-only on Postgres/MySQL), the adapter rewrites $nplaceholders to the driver's bind style, the statement runs under timeoutMs, and results cap at maxRows with a truncated flag. Rows come back strictly JSON-safe — dates and engine-native types are normalized (e.g. Date → ISO string) identically on every engine.
  5. A data_calls provenance row is appended — on success and on failure (ok · error · timeout · blocked) — recording the exact SQL, params used, row count, duration, and executor.

The allowRawSql escape hatch

With allowRawSql enabled on a connector, tools and the API may submit ad-hoc SQL for exploration. The same guards still apply — read-only single-statement enforcement, maxRows, timeoutMs, and a full provenance row with the raw statement text. Without the flag, raw SQL is refused with raw-sql-forbidden. Named components remain the recommended surface: they are the difference between a governed data layer and a SQL prompt-injection target.

The Data page

Everything above is managed in the workspace Data page (/w/:workspaceId/data):

  • Connectors — the create/edit dialog opens with an Engineselector (PostgreSQL · MySQL · Snowflake) that switches the config form to that engine's fields and default port; the engine is fixed once the connector is created. Secrets are entered once and masked thereafter; enable/disable, test connection with live status, and the governance caps live in the same dialog.
  • Schema browser — the discovered snapshot per connector: tables, columns, types, row estimates, the discoveredAt stamp, a refresh action, and snapshot-validated previews.
  • Domain components — author the SQL template and param specs with save-time validation (or start from an LLM draft), and preview-run a component with sample params to see real rows before exposing it.
  • Call history — the recent data_calls provenance trail: status, duration, row count, and which run/conversation triggered each call.

Mutations are owner/admin-gated; all of it is workspace-scoped under FORCE-RLS like every other tenant surface.

Next

Components become live answers once the graph points at them — see Entity bindings & live data.