Skip to content

Configuration System

nl2sql reads one config: nl2sql.yaml and the files it includes. Loading it gives one typed Config object, and building that same Config in Python gives exactly the same engine. Everything the engine needs is in it. Only the config path and the provider key variables are read directly (see Environment variables); any other value from the environment is written in the YAML as ${env:VAR}.

The design and its reasoning are in Configuration v2. This page is the reference.

nl2sql.yaml

nl2sql init writes a new project, with each section in its own file:

nl2sql init my-project              # an example SQLite datasource, allow_all policies
nl2sql init my-project --demo       # the demo: three sample databases, roles, verified queries
nl2sql init my-project --provider anthropic
nl2sql.yaml            # the table of contents
datasources/
  example.yaml         # one file per datasource (--demo: chinook.yaml, support.yaml, webanalytics.yaml)
llm.yaml               # provider + model; no key, the provider's standard variable is read
embeddings.yaml        # provider: local
policies.yaml          # allow_all: true            (--demo: admin, analyst, viewer roles)
storage.yaml           # path: .nl2sql
curated.yaml           # --demo only: the demo's verified queries
settings.yaml          # --demo only: the demo's tuning knobs
data/                  # --demo only: the three SQLite files, copied
.gitignore             # .nl2sql/ and .env

init refuses to overwrite a file without --force, never writes a key, and checks what it wrote with load_config.

# nl2sql.yaml
version: 2
datasources: { file: datasources/ }      # a folder, one file per datasource
llm:         { file: llm.yaml }
embeddings:  { file: embeddings.yaml }
policies:    { file: policies.yaml }
storage:     { file: storage.yaml }
curated:     { file: curated.yaml }      # optional
secrets:     { file: secrets.yaml }      # optional
settings:    { file: settings.yaml }     # optional
api:         { file: api.yaml }          # optional, read by nl2sql-api only
observers:   { file: observers.yaml }    # optional: where events, logs and metrics go
hooks:       { file: hooks.yaml }        # optional
evals:       { file: evals.yaml }        # optional

A small project can write every section inline instead. This file builds the same Config as a split layout would:

version: 2
datasources:
  - id: shop
    type: sqlite
    connection: { database: shop.db }
llm:        { provider: anthropic, model: claude-sonnet-5-5 }
embeddings: { provider: local }
policies:   { allow_all: true }
storage:    { path: .nl2sql }
Section Required Page Notes
version yes 2. Anything else, or none, is an error that points to the 0.4 mapping.
datasources yes Datasources A list; [] is enough.
llm yes LLMs The model every agent uses, and per-agent overrides.
embeddings yes Embeddings {provider: local} is a complete section.
policies yes Policies {allow_all: true} is a complete section.
storage yes Storage {path: .nl2sql} is a complete section.
curated no Curated Answers Present means curated answers are on.
secrets no Secrets The env provider is always there without it.
settings no Settings Tuning knobs; every field has a default.
api no REST API nl2sql-api's own settings; the engine ignores them.
observers no below A list. Absent means the default observers, log and audit; [] means none.
hooks no below A list, run in its order.
evals no below The eval suites, scorers and regression gate.

From Python:

from nl2sql import NL2SQL, load_config

engine = NL2SQL.from_config("nl2sql.yaml")        # path=None: NL2SQL_CONFIG, then ./nl2sql.yaml
cfg = load_config("nl2sql.yaml")                  # the Config, to change in code first
engine = NL2SQL(cfg.model_copy(update={"settings": cfg.settings.model_copy(update={"tenant_id": "acme"})}))

See Public Facade → Creating an engine.

file: includes

Rule Detail
Shape A section is either inline, or exactly {file: <path>} with no other keys.
Relative paths The path is relative to nl2sql.yaml, not to the working directory.
One level Only nl2sql.yaml includes. An included file holds the section's body and includes nothing itself.
No version Only nl2sql.yaml has version:.
Datasource folder datasources: {file: datasources/} names a folder. Every *.yaml in it, sorted by name and not recursive, holds one datasource. id may be left out and defaults to the file name; if it is given, it must equal the file name.
Datasource file datasources: {file: datasources.yaml} names a file whose body is the list.
List sections observers and hooks are lists too: their file's body is the list.
Literal paths file: values are never interpolated.
Each file once Including the same file twice is an error.

Other relative paths are relative to nl2sql.yaml too: storage.path, and a SQLite or DuckDB database. The engine can be run from any working directory.

Secret and variable references

${env:SALES_DB_PASSWORD} reads an environment variable; ${vault:apps/sales:password} reads a secret from the provider whose id is vault (see Secrets). env is built in.

Rule Detail
Whole value only A reference is the entire string. "postgres://${env:U}@db" is an error, and there is no default syntax.
Secrets resolve late Every api_key, the secret providers' credentials and every value under a datasource's connection keep the reference. It is resolved when the component is built, so engine.config, logs and errors never hold a plaintext secret.
Everything else resolves at load Any other field, for example storage.path: "${env:NL2SQL_DATA_DIR}", is resolved by load_config. A variable that is not set is an error naming the field.
Inside secrets: Only ${env:...}: one provider cannot depend on another.

Observers, hooks and evals

Three sections added in release 0.7 (Wave A). They are accepted and validated now; the engine runs the hooks and the observers, and reads evals from the Wave A change named below. The observers are documented on their own page, Observers.

# observers.yaml: where the engine's events go
- type: log
  level: INFO
- type: audit
  max_age_days: 90
- type: otel                      # needs nl2sql-engine[otel]
  endpoint: http://collector:4317
- type: corp_siem                 # a plugin; its config_model checks the rest
  url: "${env:SIEM_URL}"

# hooks.yaml: code that sees every run at fixed points, in this order (see Hooks)
- type: corp_sql_review
  points: [execute.before]        # a subset of the hook's own points; all of them by default
  on_error: closed                # closed (the default) | open
  approver_roles: [dba]           # who may approve its needs_approval; default policies.admin_roles
  url: "${env:REVIEW_URL}"        # the hook's own keys

# evals.yaml: the suites engine.evals.run runs, and how they are scored
suites: [evals/chinook.yaml]      # relative to nl2sql.yaml
scorers: [result_match, outcome, faithfulness, retrieval_recall, routing]   # the default: all five
curated: true
max_cost_usd: 5.00                # across a run in mode full; required for it
gate:
  max_regressions: 2
  alpha: 0.05
  max_cost_increase: 0.20
Section Item Rule
observers {type: <name>, ...} (ObserverConfig) or an observer object in code type names a registered observer (nl2sql.observers); its config_model checks the other keys, and keeps their ${...} references until it is built. Without the section the engine runs log and audit; observers: [] runs none.
hooks {type: <name>, points, on_error, approver_roles, ...} (HookConfig) or a hook object in code points takes run.before, datasources.after, plan.after, execute.before, execute.after and answer.after; any other is an error. on_error is closed or open. The hook's config_model checks its own keys. A point the hook does not declare is an error. See Hooks.
evals EvalsConfig Strict keys. suites are files of cases, read when a run asks for them, not when the engine starts. gate.alpha is between 0 and 1. See Evals.
from nl2sql import Config, HookConfig, ObserverConfig

Config(..., observers=[ObserverConfig(type="log"), MyObserver()],
       hooks=[HookConfig(type="corp_sql_review", points=["execute.before"]), MyHook()])

Strict keys and errors

Every section forbids unknown keys and suggests the closest known one. Names resolved through the registry (type:, provider:) suggest from the installed names. load_config and NL2SQL(...) collect every problem before raising one ConfigError, each naming the file it is in (an included file, not nl2sql.yaml):

nl2sql.yaml has 2 problems:

  datasources/sales.yaml → preferd_tables
    Unknown key. Did you mean 'preferred_tables'?

  llm.yaml → provider
    'antropic' is not an installed LLM provider. Did you mean 'anthropic'?

A secret is shown as its reference, never as its value. A 0.4 file (version: 1) gets one message pointing to the mapping.

Environment variables

Only these are read directly:

Variable Read by Purpose
NL2SQL_CONFIG load_config() with no path, the CLI, nl2sql-api Path to nl2sql.yaml. Without it, ./nl2sql.yaml.
NL2SQL_LOG_LEVEL configure_logging() The log level when the caller passes none (DEBUG, INFO, ...). Without it, INFO.
NL2SQL_LOG_FORMAT configure_logging() text or json (one JSON object per line, with its trace_id) when the caller chooses neither; the CLI's --log-format chooses for any command. Without it, text; any other value is an error (ValueError; the CLI prints it on one line and exits 1).
OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY nl2sql/llm/ The fallback key when llm.api_key (or an agent's, or embeddings.api_key) is not set.

Anything else from the environment is written in the YAML as ${env:VAR}. There are no generic overrides such as NL2SQL__SECTION__KEY. Third-party libraries (the OpenTelemetry SDK) still read their own standard variables, the trace redactor scans the environment for values to mask. Nothing in nl2sql writes the environment: a key the demo is given travels in the engine's LLM config, and only the .env loading below adds variables. The architecture tests enforce both (invariants).

.env files

Only the entry points load a .env file: the CLI, nl2sql-api and nl2sql demo load the .env next to nl2sql.yaml into the process environment at startup. A variable that is already set always wins; the file never overrides it. Each loads the .env before it configures logging, so a NL2SQL_LOG_LEVEL written there applies (nl2sql demo reads the demo project's .env, except with --hosted, which reads none).

The library itself never reads a .env file. A Python caller that wants one loads it before building the engine:

from dotenv import load_dotenv
from nl2sql import NL2SQL

load_dotenv("my-project/.env")   # or nl2sql.load_env_file("my-project/nl2sql.yaml")
engine = NL2SQL.from_config("my-project/nl2sql.yaml")

nl2sql init adds .env to .gitignore. Keep keys there, or in the environment, never in the YAML.

The CLI

Every command reads the same config, named once, before the subcommand:

nl2sql --config prod/nl2sql.yaml index
nl2sql --config prod/nl2sql.yaml run "Top 5 customers by revenue"
NL2SQL_CONFIG=prod/nl2sql.yaml nl2sql curated validate
nl2sql run "..."                       # ./nl2sql.yaml
nl2sql --log-format json run "..."     # JSON log lines, for any command

There are no per-command path flags. Commands that query or index build the engine and call engine.check() first (see Startup). nl2sql cache, nl2sql feedback and nl2sql trace show only need the store paths under storage.path, and build no engine.

Checking a config: nl2sql config check

nl2sql config check checks the whole config without starting the engine, so it fits a CI job or a pre-deploy step:

nl2sql --config prod/nl2sql.yaml config check            # files, names, drivers
nl2sql --config prod/nl2sql.yaml config check --connect  # and connect to every datasource

It does what building the engine does, short of the stores:

  • reads nl2sql.yaml and every file it includes, and resolves ${env:...};
  • loads every name the config uses through the registry (type:, provider:, the secret providers): the name is installed, implements the interface version this engine needs, and its drivers are installed;
  • builds the components, which resolves each datasource's connection references, without connecting or calling a model.

With --connect it also connects to every datasource, as engine.check() does, and a datasource that fails is a problem (required or not).

Problems are printed by the file they are in, then a line per secret:

datasources/sales.yaml
  type
    Unknown adapter 'postgress'. Did you mean 'postgres'? Registered: duckdb, mssql, mysql, postgres, sqlite.

llm.yaml
  provider
    Unknown LLM provider 'antropic'. Did you mean 'anthropic'? Registered: anthropic, ollama, openai, openrouter.

Secrets:
  ${env:SALES_DB_PASSWORD} missing (datasources/sales.yaml → connection.password)

prod/nl2sql.yaml: 2 problems in 2 files.

A secret is reported as present or missing, never printed. Where the llm section names no api_key, the provider's standard variable is listed. A missing LLM key is reported but is not a problem, as for the engine: a CI job rarely holds the production key, and engine.check() reports it at startup. Any other secret that does not resolve stops the engine from building, so it is a problem.

Exit code Meaning
0 No problems.
1 One or more problems, or the config was not found.
2 A usage error: an unknown option.

The check creates no file under storage.path. From Python, the same check is nl2sql.config.check.check_config(path, registry=..., connect=...), which returns the problems, the secrets and the datasource statuses.

Startup: check() and required

Building the engine never connects to a datasource or calls a model: every problem it can find without a connection is one ConfigError. engine.check() then connects to each datasource, checks that every agent's API key resolves (no model call) and reports the index status.

The CLI, nl2sql-api (in its lifespan, before it accepts requests) and nl2sql demo call check() at startup:

  • A datasource with required: true that fails, or datasources configured and none ready, raises EngineNotReady: the entry point prints the report and refuses to start (the CLI exits 1).
  • An optional datasource that fails is printed, left out of routing, and the entry point runs degraded.
# datasources/sales.yaml
type: postgres
required: true           # without it, nothing works: refuse to start
connection: { host: db.internal, database: sales, user: nl2sql, password: "${env:SALES_DB_PASSWORD}" }

A Python caller decides for itself. Without check(), a broken datasource fails the first query that targets it, with its reason.

From 0.4

0.5 reads only nl2sql.yaml. There is no migration command: map each 0.4 file, variable and flag with these tables. nl2sql init writes a fresh layout to start from.

Files

0.4 0.5
configs/datasources.yaml (DATASOURCE_CONFIG) datasources:, one file per datasource in datasources/. connection.type moves up to type; options: and the top-level limits become limits: {row_limit, timeout_ms, max_bytes}.
configs/llm.yaml (LLM_CONFIG) llm:, flat: default:'s keys are the top level; agents: are keyed by node name (ast_planner, not astplanner); name: and version: are gone.
configs/policies.json (POLICIES_CONFIG) policies:, in YAML; the role: field that repeated the key is gone.
configs/secrets.yaml (SECRETS_CONFIG) secrets:, without version. hashi is now hashicorp.
configs/curated.yaml (CURATED_CONFIG) curated:, without version. Present means on.
configs/sample_questions.yaml (SAMPLE_QUESTIONS, ROUTING_EXAMPLES) sample_questions: on each datasource.
.env, .env.<name> (ENV, APP_ENV, ENV_FILE_PATH) The .env next to nl2sql.yaml, loaded by the entry points only.
.env.demo and configs/*.demo.* nl2sql init --demo (and nl2sql demo, which runs it): nl2sql.yaml, its files, and .env.

Settings variables

0.4 variable 0.5
OPENAI_API_KEY llm.api_key / embeddings.api_key, or the fallback variable above
VECTOR_STORE storage.path (the index is <path>/index/)
VECTOR_STORE_COLLECTION removed (an internal constant)
EMBEDDING_PROVIDER embeddings.provider
EMBEDDING_MODEL embeddings.model
TENANT_ID settings.tenant_id
CURATED_ENABLED removed: the curated section is present or absent
CURATED_MATCH_TOP_K settings.curated.match_top_k
CURATED_MATCH_FLOOR settings.curated.match_floor
GLOBAL_TIMEOUT_SEC settings.pipeline.timeout_s
RESULT_ARTIFACT_BASE_URI storage.path (<path>/results/)
RESULT_ARTIFACT_PATH_TEMPLATE removed: a fixed layout under <path>/results/
RESULT_ARTIFACT_BACKEND, RESULT_ARTIFACT_S3_*, RESULT_ARTIFACT_ADLS_* removed: results are local; the S3 and ADLS backends are deleted
SCHEMA_STORE_BACKEND removed: SQLite at <path>/state.db
SCHEMA_STORE_PATH storage.path (<path>/state.db, which also holds the plan cache and feedback)
SCHEMA_STORE_MAX_VERSIONS settings.schema.max_versions
SCHEMA_VERSION_MISMATCH_POLICY removed: a run reads the schema version its index build was made from
PLAN_CACHE_ENABLED settings.pipeline.plan_cache
FEEDBACK_ENABLED settings.feedback.enabled
SCHEMA_RETRIEVAL_FULL_SNAPSHOT_MAX_TABLES settings.retrieval.full_snapshot_max_tables
LOGICAL_VALIDATOR_STRICT_COLUMNS settings.pipeline.strict_columns
RBAC_REFUSAL_NAMES_TABLES policies.refusal_names_tables
SQL_AGENT_MAX_RETRIES settings.pipeline.retries.max
SQL_AGENT_RETRY_BASE_DELAY_SEC settings.pipeline.retries.base_delay_s
SQL_AGENT_RETRY_MAX_DELAY_SEC settings.pipeline.retries.max_delay_s
SQL_AGENT_RETRY_JITTER_SEC settings.pipeline.retries.jitter_s
LLM_PRICES llm.prices
TRACE_MODE settings.traces.mode
TRACE_DIR storage.path (<path>/traces/)
RESULT_ROW_LIMIT settings.results.row_limit
RESULT_FETCH_BATCH_SIZE settings.results.fetch_batch_size
ANSWER_PREVIEW_ROWS settings.results.preview_rows
TRACE_SAMPLE_ROWS settings.traces.sample_rows
TRACE_MAX_FIELD_CHARS settings.traces.max_field_chars
NL2SQL_TODAY settings.pipeline.today
OBSERVABILITY_EXPORTER an otel observer in the observers section (0.7)
OTEL_EXPORTER_OTLP_ENDPOINT the otel observer's endpoint (0.7)

Other variables

0.4 variable 0.5
NL2SQL_API_ROLE_HEADER api.role_header
NL2SQL_API_ROLE api.role
NL2SQL_API_TRUST_BODY_ROLE api.trust_body_role
NL2SQL_API_CORS_ORIGINS (comma-separated) api.cors_origins (a list)
NL2SQL_API_ADMIN_ROLES policies.admin_roles
NL2SQL_DEMO_HOSTED nl2sql demo --hosted
NL2SQL_DEMO_QUESTIONS_PER_MINUTE, NL2SQL_DEMO_QUESTIONS_PER_SESSION nl2sql demo --questions-per-minute N --questions-per-session N
VAULT_ADDR, VAULT_TOKEN secrets.providers[].url / .token (write ${env:VAULT_TOKEN} to keep reading the variable)
AZURE_KEYVAULT_URL secrets.providers[].vault_url
ANTHROPIC_API_KEY, OPENROUTER_API_KEY unchanged: the fallback key variables

CLI

0.4 0.5
nl2sql setup nl2sql init [DIR] [--provider NAME] [--force]; the guided flow comes in a later release
nl2sql setup --demo nl2sql init --demo [DIR] (or just nl2sql demo)
--env NAME, --env-file PATH removed: the .env next to nl2sql.yaml is loaded
--config PATH (the datasource file), --llm-config, --secrets-config, --policies-config, --vector-store the one global nl2sql --config PATH/nl2sql.yaml
curated validate/test --curated --config --secrets nl2sql --config ... curated validate/test
policy validate --config --policies --secrets nl2sql --config ... policy validate
nl2sql --env demo <command> (from the demo folder) nl2sql --config nl2sql-demo/nl2sql.yaml <command>, or run it from inside nl2sql-demo/

Source references

  • Models and load_config: packages/nl2sql/src/nl2sql/config/
  • init_project, demo_config: packages/nl2sql/src/nl2sql/config/project.py
  • The runtime built from a Config: packages/nl2sql/src/nl2sql/runtime.py
  • The CLI's --config: packages/nl2sql/src/nl2sql/cli/common/engine.py
  • nl2sql config check: packages/nl2sql/src/nl2sql/config/check.py, printed by packages/nl2sql/src/nl2sql/cli/commands/config_check.py