Skip to content

Public Facade API

Purpose

Provide one entry point: NL2SQL is built from one Config, owns everything it builds (its Runtime), and is the only thing an application talks to. Questions are top-level methods; everything else is a namespace.

Responsibilities

  • Build the engine from a Config, resolving every name through a Registry.
  • Connect, and report what is ready, only when asked (check()).
  • Release what it opened (close()).
  • Answer questions (run_query, stream_query) and group the rest by subject (engine.datasources, engine.index, ...).

Key Modules

  • packages/nl2sql/src/nl2sql/public_api.py: the facade
  • packages/nl2sql/src/nl2sql/runtime.py: Runtime.from_config, what one engine builds
  • packages/nl2sql/src/nl2sql/check.py: CheckReport, DatasourceStatus, EngineNotReady

Creating an engine

Every way builds the same Config (see Configuration v2, section 4). Names in it (type: postgres, provider: anthropic) are resolved through a Registry; Registry.default() is used when none is passed.

constructor meaning
NL2SQL(config, *, registry=None) Loads and validates: resolves every name, imports only the components the config uses, checks interface versions and driver modules, builds the secret manager, the adapters (not connected), the LLM settings (no model call), the embedder and the stores under storage.path. Raises one ConfigError listing every problem. It never connects to a datasource or calls a model.
NL2SQL.from_config(path=None, *, registry=None) NL2SQL(load_config(path), registry=registry). path defaults to $NL2SQL_CONFIG, then ./nl2sql.yaml.
NL2SQL.demo(*, llm=None, path=".nl2sql-demo") The demo: the three bundled SQLite databases, opened read-only from the installed package, with the demo roles and verified queries. Without llm, the first provider whose key variable is set is used with its default model; with none set, a ConfigError names the variables.
from nl2sql import NL2SQL, Config, Datasource, Embeddings, LLM, Policies, Storage, load_config

engine = NL2SQL.from_config("nl2sql.yaml")

engine = NL2SQL(Config(
    datasources=[Datasource(id="shop", type="sqlite", connection={"database": "shop.db"})],
    llm=LLM(provider="anthropic", model="claude-sonnet-5-5"),
    embeddings=Embeddings(provider="local"),
    policies=Policies.allow_all(),
    storage=Storage(path=".nl2sql"),
))

cfg = load_config("nl2sql.yaml").with_datasource(
    Datasource(id="hr", type="sqlite", connection={"database": "hr.db"}))
engine = NL2SQL(cfg)

What one engine builds belongs to it alone: its secret manager (resolving ${provider:key} references through the providers in secrets), its embedder (from embeddings, or the instance given), and its stores:

store location
Vector index <storage.path>/index/
Schema snapshots, plan cache, feedback, the index lock the state store, <storage.path>/state.db by default
Run traces <storage.path>/traces/
Result files <storage.path>/results/ (see Artifact Store)

A relative storage.path, and a relative SQLite or DuckDB file in a datasource's connection, are read from the folder of nl2sql.yaml when the config was loaded from a file, and from the working directory when it was built in code. engine.config is the current frozen Config, with secrets as references only; datasources.add, datasources.remove and llm.replace are the calls that change it.

Each engine owns its config: nothing is process-global and nothing writes the environment, so two engines in one process share nothing. The CLI, nl2sql-api and the playground build their engine from nl2sql.yaml and call check() at startup.

Lifecycle

with NL2SQL.from_config("nl2sql.yaml") as engine:
    report = engine.check()
    if not report.ok:
        print(report)
    result = engine.run_query("Top 5 customers by revenue")
# close() ran: pools disposed, stores closed
method meaning
check() -> CheckReport Connects to each datasource and records it ready, or failed with the stage the adapter found (config, driver, network, auth, database or permission; none when it cannot tell), the reason and a fix. A ready datasource whose adapter does not declare ENFORCES_READ_ONLY carries a warning in warnings: its session can write. A failed datasource is marked unavailable and left out of routing: the engine runs degraded, and a query that targets it fails with the reason. It also reports each agent whose API key does not resolve (no model is called) and the index status from index.health(). Raises EngineNotReady if a required: true datasource fails, or if datasources are configured and none is ready; the error carries the report. Safe to call again: a datasource that connects on a later call is routed to again.
close() Disposes each adapter's connection pool (an adapter's optional close()) and closes the state store and the vector store client. run_query, stream_query and check raise afterwards. Calling it twice is fine.
clear_plan_cache() -> int Removes every cached plan from the state store and returns how many there were. Schema snapshots and feedback stay; the next run of each question calls the planner. nl2sql cache clear calls it.
with NL2SQL(...) as engine: __exit__ calls close().
config The current frozen Config.
closed Whether close() ran.

CheckReport has datasources (one DatasourceStatus each: id, status, required, stage, reason, fix), llm (agent to reason, for each key that does not resolve), index, and ok, true when every datasource is ready, every key resolves and the index is built. str(report) is a readable summary.

Building an engine is cheap and has no side effects outside storage.path, so it suits tests and notebooks. Startup policy belongs to the entry point: call check() straight after building the engine, refuse to start on EngineNotReady, and start degraded, printing the report, when an optional datasource fails.

The shape

engine.run_query(question, *, datasource_id=None, execute=True, user_context=None, cancellation_token=None)
engine.stream_query(question, *, datasource_id=None, execute=True, user_context=None, cancellation_token=None)
engine.check(); engine.close(); engine.config; engine.about(); engine.clear_plan_cache()

engine.datasources   # list, get, types, test, add, remove, schema
engine.index         # build, health, clear, inspect, inspect_defaults
engine.llm           # list, get, providers, prices, replace
engine.policies      # roles, allows, allowed, validate
engine.curated       # validate, test
engine.results       # manifest, page, csv
engine.feedback      # record, list, stats, export, clear
engine.traces        # get, replay
engine.storage       # info, purge
engine.events        # subscribe, catalog
engine.evals         # load, run, regenerate, scorers
namespace reference
run_query, stream_query, results Query API
datasources Datasource API
index Indexing API
llm LLM API
policies Policies API
curated Curated answers → Checking them
feedback below, and Feedback and signals
traces, about() below
events Events
evals Evals

The namespace classes are internal: reach them through the engine, never by importing them. Each is built on first use.

The CLI, the playground and nl2sql-api are callers like any other: they use only these methods and namespaces, never the runtime behind them. test_the_cli_the_playground_and_the_api_use_only_the_public_api in tests/architecture/test_boundaries.py holds them to that, and test_the_playground_the_demo_command_and_the_api_import_only_the_public_nl2sql holds the playground, nl2sql demo and nl2sql-api to importing from the top-level nl2sql only (the playground and nl2sql demo keep a short, named list of exceptions in that test, each with the reason and the change that removes it).

About

engine.about() describes the engine and its pipeline: version (of nl2sql-engine), git_sha (when running from a checkout, else None), python, and steps, one dict per step of a run in the order a run takes them. Each step has its node (the name usage and timings are recorded under), label, what it does, its kind (model when a model decides it, code otherwise) and its parent (sql_agent for the steps nested in the SQL agent). A model step also has its agent and the provider and model it is configured to run on; never a key. The playground's Pipeline page reads it.

about = engine.about()
[(s["node"], s["model"]) for s in about["steps"] if s["kind"] == "model"]
# [('curated_matcher', 'gpt-5.4'), ('datasource_resolver', 'gpt-5.4'), ...]

Feedback

Ratings of answered runs, and the guardrail rates over recorded runs. They live in the state store (<storage.path>/state.db by default); the rates also read the traces in <storage.path>/traces. nl2sql feedback and the playground's thumbs up and down are built on it. See Feedback and signals for what is stored.

method does
record(result, *, question, role, rating, note=None) -> dict Rate one run, "up" or "down", with an optional note (cut to 280 characters). result is the run's QueryResult or its JSON. The stored row is built from it and from the run's trace when one was kept; never rows, the answer, error messages or keys, and a run refused for the role keeps no SQL. A later rating of the same run replaces the earlier one. Returns the row; raises ValueError for another rating or a result with no trace_id.
list(limit=None) -> list[dict] Every rating, newest first.
stats(*, traces=None) -> dict Rates over the ratings plus the traces in traces (default: the engine's traces folder): feedback, refusals, refiner retries, validator failures, errors, and the plan cache's hit rate.
export(path) -> dict Write the thumbs-up runs to path as draft gold entries for review; {"written", "skipped"}. Raises ValueError for the gold set's own path.
clear() -> int Delete every rating; returns how many.
result = engine.run_query("How many customers are there?")
engine.feedback.record(result, question="How many customers are there?", role="admin", rating="up")
engine.feedback.stats()["feedback"]   # {'up': 1, 'down': 0, 'up_rate': 1.0, 'down_rate': 0.0}

Traces

engine.traces.get(trace_id) returns one recorded run's trace document, or None when none was kept (which runs are kept is settings.traces.mode, and for how long the trace store's max_age_days). It reads through the trace store, storage.traces (the files store reads only <storage.path>/traces), and the id must be a plain token (letters, digits, - and _), so no id can name a file elsewhere: anything else raises ValueError, as does a file in another trace format.

Storage

engine.storage.info() returns each store slot's type and location (and max_age_days for the traces and results stores that have it) and the current index namespace. engine.storage.purge() deletes the traces and results older than their store's max_age_days, and the audit observer's day files older than its own, and returns the counts, {"traces": 3, "results": 12, "audit": 1}, which it also publishes as retention.purged. See Storage → Retention.

engine.traces.replay(trace) re-runs a recorded question, answering every LLM call from the trace document (no model is called), on a runtime of its own so the engine's config and LLMs never change. It returns a ReplayReport: the replayed result, how many recorded calls were served, the divergence (where the replay asked for a call the recording does not have) and the differences from the recorded result. It raises LookupError when a datasource the trace used is not configured here or does not answer; the message names each one and why. nl2sql trace replay is built on it (see Debugging).

engine = NL2SQL.demo()
admin = UserContext(roles=["admin"])                         # the schema is always one caller's view
engine.datasources.schema("chinook", user_context=admin)["tables"][0]["name"]   # 'Album'
engine.index.health()["status"]                              # 'ok'
engine.index.inspect("top customers", k=4)["picks"]

Top-level exports

Clients import everything they need from nl2sql itself, never from an engine submodule: NL2SQL, QueryResult, SubQueryResult, RowSample, AnswerSource, ResultSummary, ResultManifest, ResultPage, ResultNotFound, MAX_RESULT_PAGE_SIZE, QuestionUsage, UserContext, CancellationToken, configure_logging, TraceContextFilter, the event types (Event, Observer), a run's ending (RunStop, ApprovalRequest, Approval, VerifiedRef), what the namespaces return (DatasourceInfo, DatasourceType, IndexProgress, LLMChange), the config (Config, load_config, load_env_file, ConfigError, Registry and the section classes Datasource, Limits, LLM, AgentLLM, Embeddings, Policies, Role, Storage, StoreConfig, Secrets, Curated, Settings, ObserverConfig, HookConfig, EvalsConfig), what check() reports (CheckReport, DatasourceStatus, EngineNotReady), the error types (PipelineError, ErrorCode, ErrorSeverity), and the evals' shapes (EvalSuite, EvalCase, EvalReport, RegenerateReport) and scorer interface (Scorer, Score, CaseRun). The list is fixed by tests/unit/test_public_surface.py: adding or removing a name is a semver decision. The REST API (nl2sql-api) is held to importing only from nl2sql by an architecture test.