Skip to content

Query API

Purpose

Answer a question with the NL2SQL pipeline: engine.run_query, engine.stream_query, and engine.results to read back what a run saved.

Responsibilities

  • Invoke pipeline graph runtime.
  • Return a structured QueryResult, or the run as events.
  • Read a run's saved results by its trace_id.

Key Modules

  • packages/nl2sql/src/nl2sql/public_api.py (NL2SQL.run_query, NL2SQL.stream_query)
  • packages/nl2sql/src/nl2sql/api/query_api.py (QueryResult, result_from_state)
  • packages/nl2sql/src/nl2sql/api/results_api.py (ResultsAPI, internal)
  • packages/nl2sql/src/nl2sql/pipeline/runtime.py
  • packages/nl2sql/src/nl2sql/pipeline/graph.py
  • packages/nl2sql/src/nl2sql/pipeline/state.py

Public Surface

NL2SQL.run_query

Source: packages/nl2sql/src/nl2sql/public_api.py, packages/nl2sql/src/nl2sql/api/query_api.py

Signature: run_query(question: str, *, datasource_id: Optional[str] = None, execute: bool = True, user_context: Optional[UserContext] = None, cancellation_token: Optional[CancellationToken] = None, plan: Optional[SuppliedPlan] = None, approvals: Optional[Sequence[Approval]] = None) -> QueryResult

Every argument after the question is keyword-only. run_query passes the runtime's artifact store to result_from_state, so each sub-query carries a capped row sample alongside its artifact reference.

Parameters: | name | type | required | meaning | | --- | --- | --- | --- | | question | str | yes | The question, in plain language. | | datasource_id | Optional[str] | no | Datasource override; otherwise resolved. | | execute | bool | no | Whether to execute SQL against datasource. | | user_context | Optional[UserContext] | no | RBAC context. Omitted (or None), the run starts with an empty UserContext, a caller with no roles, and RBAC treats it exactly as it treats any roles list it has no policy for. Pass one with a role to be granted anything. | | cancellation_token | Optional[CancellationToken] | no | A token (from nl2sql import CancellationToken) the caller cancels from another thread to stop the run. No further step and no further model call starts once it is cancelled; the step in flight finishes, and the result carries one CANCELLED error. The playground cancels it when the browser drops the request. | | plan | Optional[SuppliedPlan] | no | A plan the caller holds (from nl2sql import SuppliedPlan: datasource_id, sub_query, plan). See Running a plan you hold. A datasource_id passed with it must be the plan's own, or the call raises ValueError before anything runs. | | approvals | Optional[Sequence[Approval]] | no | Approvals for an earlier run's approvals_needed. See Approval. |

Running a plan you hold

run_query(question, plan=SuppliedPlan(datasource_id=..., sub_query=..., plan=...)) runs one sub-query's plan without routing or planning: the curated matcher, the datasource resolver, the decomposer and the planner's model call are skipped. What is not skipped:

  • The datasource is checked against user_context's scope, as the resolver would; a datasource the caller may not read ends the run with SECURITY_VIOLATION before anything runs.
  • The plan passes the plan.after hooks, the logical validator (so a table outside the caller's scope is refused) and the generator, like any plan.
  • It is never written to the plan cache, and a validator failure ends the sub-query with the validator's error: the refiner is never asked for a different plan. A database error is not retried either.

The sub-query's plan_source is "supplied". The question is still asked: the answer is written for it. The same entry serves the approved re-run ("approved") and, in Wave B, a plan the asker chose ("chosen").

Approval

A hook at execute.before may answer NeedsApproval(reason). The run then ends with status: "needs_approval", the error NEEDS_APPROVAL and one ApprovalRequest per pending sub-query in approvals_needed: the question, the asker's user_id and tenant_id, the hook, the datasource, the run's decomposition, the sub-query and its plan (or, on the verified path, the verified query's id and values), the SQL and its SHA-256, the reason, and the roles that may approve (for display). Its id is a SHA-256 over a fixed list of those fields (REQUEST_ID_FIELDS: all but the reason and the roles), each without the values it holds at their defaults, so an edit shows, the same request always has the same id, and a field added in a later version keeps the ids of requests an app stored before.

An approver re-runs the question as the asker:

result = engine.run_query(question, user_context=asker)
if result.status == "needs_approval":
    approvals = [Approval(request=r, by=approver) for r in result.approvals_needed]
    result = engine.run_query(question, user_context=asker, approvals=approvals)

The engine, not the hook, checks each approval. An approval pins nothing when its request was edited (its id no longer matches), its sql is not the SQL of its sql_sha256 (the SQL an approver reads is the SQL whose hash is checked), it is for another question or another asker, its hook is not configured, or by holds none of that hook's approver roles. Then the run plans as usual and the hook asks again. Otherwise the re-run takes the request's decomposition and plan (plan_source: "approved", no resolver, decomposer or planner call) and validates and generates the SQL again. At execute.before the approval counts as the hook's Allow only when it is for that hook and datasource, the hash of the SQL just generated equals the request's (and, on the verified path, the values are the same), and by holds one of the hook's configured approver_roles (by default policies.admin_roles), never the roles the request lists. Then the next hook still runs. Anything else asks again: the run ends needs_approval with a new request. Nothing is stored, and an Approval is not signed and does not expire: it is trusted as far as the user_context a caller passes. See Hooks.

Returns: QueryResult, built from the pipeline graph state by result_from_state.

Raises: No exceptions are raised by run_query; errors are returned in QueryResult.errors.

Side Effects: - Pipeline execution, LLM calls, optional database execution.

Idempotency: - Not guaranteed; execution can depend on external systems and time.

Execution Lifecycle

  • Use the caller's CancellationToken, or create one per run, and install signal / Ctrl+X handlers bound to it.
  • Pass the graph a callback that raises RunCancelled at the next node start or model call once the token is cancelled.
  • Build LangGraph pipeline from build_graph.
  • Execute graph in thread pool with timeout.
  • On timeout/cancel, return PipelineError with appropriate ErrorCode.

QueryResult

Source: packages/nl2sql/src/nl2sql/api/query_api.py

Fields: | name | type | required | meaning | | --- | --- | --- | --- | | sub_queries | List[SubQueryResult] | no | One entry per decomposed sub-query. | | final_answer | Optional[Dict[str, Any]] | no | Answer synthesizer payload (summary, format_type, content, warnings, and truncated: True when a result was cut at the row limit). | | errors | List[Dict[str, Any]] | no | Pipeline errors (node, message, error_code, severity, provider, provider_response). provider and provider_response are set for a PROVIDER_* code and null otherwise; see provider failures. | | trace_id | str | no | Trace identifier. | | reasoning | List[Dict[str, Any]] | no | Reasoning events/logs. | | warnings | List[RunWarning] | no | What the answer should say without failing the run. See Warnings. | | artifact_refs | Dict[str, ArtifactRef] | no | Result artifact references keyed by execution node id. | | status | Optional[RunStatus] | no | "success", "plan_only", "error", "needs_approval", "stopped", "needs_clarification", or None when nothing ran and nothing failed. See below. | | timings | Dict[str, float] | no | Wall-clock seconds per graph node, plus LangGraph for the whole run. | | usage | QuestionUsage | no | LLM calls, tokens and model time per node and for the whole question. See below. | | trace_path | Optional[str] | no | Where this run's trace file was written, or None when TRACE_MODE did not write one. See Debugging a Run. | | truncated | bool | no | True when any sub-query's result reached its row limit, so rows may be missing. See sub_queries[].truncated. | | result_manifest | List[ResultSummary] | no | The run's saved results, one per terminal DAG node (node_id, columns, row_count, truncated): the manifest the aggregator writes beside them. Never the rows; see Reading a run's saved results. Empty when nothing was saved. | | source | AnswerSource | no | Where the answer came from: type "verified" with the verified query's id, datasource_id and the params it ran with, or type "generated" with the other fields None. See Curated answers. | | stop | Optional[RunStop] | no | Set when status is "stopped": the limit the run reached (max_llm_calls or max_cost_usd), its configured value, and what was spent. | | approvals_needed | List[ApprovalRequest] | no | Set when status is "needs_approval": one request per sub-query a hook asked a person to approve, with the SQL, its SHA-256, the reason and the asker. Re-run the question with an Approval for each. |

errors holds only ERROR and CRITICAL entries; WARNING-severity pipeline errors are appended to warnings as RunWarnings.

A sub-query that succeeded contributes nothing to errors: the errors of the attempts it recovered from are in warnings with detail.recovered true, and its warnings with an empty detail, each with its sub_query_id. The run trace and subgraph_outputs[<id>].errors keep every attempt's errors for debugging. A sub-query that failed without a blocking error of its own (it ended with no SQL on warnings alone) adds a MISSING_SQL error, so the run reports "error".

status is derived: "needs_approval" when a hook asked for approval (approvals_needed), "stopped" when the run reached a per-question limit (stop), otherwise "error" when any blocking error is present, otherwise "plan_only" when no sub-query produced rows or an artifact (an execute=False run), otherwise "success". A state with no sub-queries and no errors leaves it None. "needs_clarification" is reserved for Wave B's clarification result: nothing produces it yet. Hooks, approval and the caps that produce "needs_approval" and "stopped" arrive with release 0.7 (see Wave A).

Only a capped sample of the rows is inlined, in sub_queries[].rows. The full result set lives in artifact storage, addressed through artifact_refs; each combined result is listed in result_manifest and read back by the run's trace_id with the methods below.

Warnings

QueryResult.warnings is a list of RunWarning (from nl2sql import RunWarning):

name type meaning
code str An ErrorCode value, such as VALUE_NOT_FOUND, JOIN_FAN_OUT, ALL_NULL_COLUMN, JOIN_KEY_UNCONVERTED, RESULT_TRUNCATED or HOOK_DENIED; CURATED_FALLBACK when a verified query failed and the question was generated instead.
node str The node that found it.
message str What the answer says.
sub_query_id Optional[str] The sub-query it is about, when it is about one.
detail Dict[str, Any] What a program may branch on: recovered: true for an earlier attempt's error a retry replaced; the column, counts and type of a JOIN_KEY_UNCONVERTED. Never result rows or sample values.

The answer sanity checks (a missing filter value, a join that repeated the rows an aggregate counted, a column with no value) add theirs, and the answer synthesizer says them; see the executor's sanity checks. settings.pipeline.sanity_checks: false turns those checks off.

0.7 changed this shape: a warning was a dict with error_code and severity, and is now a RunWarning with code, and detail.recovered where severity said ERROR. A run's state is never kept across an upgrade (a run that ended needs_approval is asked again, not resumed; see Wave A, D10), so no older warning is ever read back.

Streaming a run

NL2SQL.stream_query

Source: packages/nl2sql/src/nl2sql/api/query_api.py, step events in packages/nl2sql/src/nl2sql/pipeline/events.py, the allow-list in packages/nl2sql/src/nl2sql/events/catalog.py

Signature: stream_query(question: str, *, datasource_id: Optional[str] = None, execute: bool = True, user_context: Optional[UserContext] = None, cancellation_token: Optional[CancellationToken] = None, plan: Optional[SuppliedPlan] = None, approvals: Optional[Sequence[Approval]] = None) -> Iterator[Event]

The same run as run_query, with the same parameters, yielded as it happens: the run's own events on the engine's bus (Events), from the caller-safe allow-list. nl2sql run shows its live progress from them.

from nl2sql import NL2SQL, UserContext

engine = NL2SQL.from_config("nl2sql.yaml")
for event in engine.stream_query("Which genre sells the most tracks?",
                                 user_context=UserContext(roles=["admin"])):
    if event.name == "node.finished":
        print(f"{event.data['node']} took {event.data['seconds']} s")
    elif event.name == "answer.delta" and event.data["field"] == "summary":
        print(event.data["text"], end="", flush=True)
    elif event.name == "run.finished":
        result = event.data["result"]   # run_query's QueryResult, as JSON

An Event (nl2sql.Event, from nl2sql_sdk.events) has a name, at (UTC), the run's trace_id, its tenant_id and a JSON-ready data dict; event.sse() is the event as one Server-Sent Event (event: <name>, and the whole event as JSON on the data line), which is how nl2sql-api and the playground send it.

name data when
run.started question, user_id, tenant_id, roles, datasource_id, execute The run began. Always first.
node.started node, sub_query_id A pipeline step began (the SQL agent's steps once per sub-query, and again on a retry). sub_query_id is None outside a sub-query.
node.finished node, sub_query_id, seconds, ok The step ended; ok is False when it raised.
result.manifest results The aggregator saved the results: results is the run's result_manifest and the event's trace_id its request_id, so engine.results.page can be called before the answer is written.
answer.delta field, text The next piece of final_answer.summary or final_answer.content, as the answer model writes it.
sanity.flagged, run.limit_reached see Events Published by the sanity checks and the caps when they arrive (Wave A's S1 and E3).
run.finished the run's summary (status, duration_s, usage, error_codes, source, datasources, tables, result_manifest, user_id, roles) plus result The run finished. result is exactly what run_query returns, as JSON; it is on the stream's copy only, never on the bus. A refusal, a provider failure, a timeout or a cancel is a run.finished with status: "error", as run_query returns it.
run.failed message, error_code The run could not start or finish (an exception outside the pipeline, such as a missing API key when the graph is built).

Every stream ends in exactly one run.finished or run.failed. Operator events (policy.decision, hook.decision, llm.call, plan_cache.lookup) are never on it: a denial would tell a denied caller which table exists, and the rest are operator data. An operator reads them with engine.events.subscribe.

How it works:

  • The run goes on a thread of its own, started on the first next(); the caller's context goes with it, so request-scoped keys (nl2sql.llm.request_key) reach the graph as they do in run_query.
  • Every run (streamed or not) has a RunScope, and QueryAPI.open_scope attaches a RunEvents to it: a LangChain callback the graph gets with the scope's callbacks, which publishes through the scope. A node's own run (its name is its langgraph_node) is a node.started / node.finished; the runnables inside a node are not events. The aggregator's end publishes result.manifest. The stream subscribes to its scope's events from the allow-list (nl2sql.events.STREAM_EVENTS), so it yields exactly what the bus carries, in publish order, and then its own run.finished with the result.
  • run_with_graph(stream_answer=True) asks the answer synthesizer to stream: its chain is streaming_structured (nl2sql.llm.wires) instead of structured, the same request with stream on. Each piece of the answer's tool call reaches on_llm_new_token; RunEvents parses the arguments so far as partial JSON and publishes what summary and content gained as answer.delta. The pieces are joined into one message before the parser sees it, so the answer, and the token usage the call reports, are the unstreamed call's. On the Anthropic wire the joined message gets the same tool-call repair as an unstreamed one.
  • Closing the iterator before the last event (a client that went away) cancels the run's token, so it stops before its next step or model call.

Token by token, per wire: both the OpenAI wire (OpenAI, OpenRouter, Ollama, any OpenAI-compatible endpoint) and the Anthropic wire stream the answer's forced tool call, so the answer streams on every provider; how finely is the provider's choice, and one that sends the whole answer at once gives one answer.delta per field. The other model steps return a typed object the next step needs whole, and are not streamed. FakeLLMServer streams both wires, a word at a time, so replay mode and the keyless tests stream too.

Reading a run's saved results

The aggregator saves each terminal result of a run as <tenant_id>/<request_id>/result_<node_id>.parquet and writes manifest.json beside them (see Artifact Store → The result manifest). engine.results reads them back by the run's trace_id (its run_id, the request_id of the REST routes). They read lazily from the parquet file: a page reads only its slice, and the CSV export reads and yields a slice at a time (CSV_CHUNK_ROWS, 10,000 rows), so neither holds the whole result in memory.

method returns meaning
results.manifest(run_id, tenant_id=None) ResultManifest request_id and results: List[ResultSummary], the same entries as QueryResult.result_manifest.
results.page(run_id, result_id, offset=0, limit=100, tenant_id=None) ResultPage Rows offset to offset + limit of the result whose node_id is result_id. limit above MAX_RESULT_PAGE_SIZE (1,000) is cut to it, and the page's limit says so. A page past the end has no rows. next_offset is where the next page starts, None at the end. A negative offset or a limit below 1 raises ValueError.
results.csv(run_id, result_id, tenant_id=None) Iterator[bytes] The whole result as UTF-8 CSV, header first, one chunk per slice. The ids are checked before the iterator is returned, so a missing result raises before any byte is sent.

ResultPage has request_id, result_id, columns, rows (rows[i] is row offset + i), offset, limit, total_rows and next_offset.

Scope: results are read from one tenant's folder, tenant_id or else the engine's own TENANT_ID, the tenant every run of this engine writes under. Each of these raises ResultNotFound (a LookupError, exported from nl2sql): an unknown run_id, a run in another tenant's folder, a result_id the run's manifest does not list, and an id that is not a plain token (letters, digits, _, -; at most 128 characters). Ids are checked before any path is built, and the parquet path is rendered from the artifact template, never read from the manifest, so no id reaches a file outside the run's folder. Nothing else guards a result yet: any caller who knows a trace_id can read it.

result = engine.run_query("List every genre.", user_context=UserContext(roles=["admin"]))
[saved] = result.result_manifest                   # node_id, columns, row_count, truncated
page = engine.results.page(result.trace_id, saved.node_id, offset=0, limit=20)
with open("genres.csv", "wb") as out:
    for chunk in engine.results.csv(result.trace_id, saved.node_id):
        out.write(chunk)

nl2sql-api serves these as GET /api/v1/query/{request_id}/results/{result_id} and .../{result_id}.csv; the playground serves the same two under /api/query/....

SubQueryResult

name type required meaning
id str no Sub-query identifier.
intent str no Semantic intent of the sub-query.
sql str no SQL generated for the sub-query (subgraph_outputs[<id>].sql_draft).
datasource_id str no Datasource the sub-query targets.
schema_version str no Schema version used for planning.
plan Optional[Dict[str, Any]] no The validated PlanModel, dumped.
validation List[ValidationCheck] no name, passed, message per validation gate.
rows Optional[RowSample] no Capped row sample; None when nothing executed or the artifact could not be read.
status str no "success" or "error" for this sub-query, from its final attempt: "success" when it ended with SQL and, if executed, a result; otherwise "error". A retry that recovers reports "success". "dropped" when a hook denied it with scope="sub_query": the run went on without it.
retry_count int no Plan/SQL refinement attempts made.
plan_source "llm" \| "cache" \| "verified" \| "supplied" \| "approved" \| "chosen" no "cache" when the plan came from the plan cache (no planner LLM call; still validated, generated and executed), "verified" when a verified query answered (no plan; sql is the verified query's own), "supplied" for a plan passed as run_query(plan=...), "approved" for an approval's pinned plan ("chosen" is reserved for Wave B), otherwise "llm". Only an "llm" plan is ever cached. See Determinism → The plan cache.
row_cap Optional[int] no The row limit that bounded this sub-query's SQL (the datasource's row_limit, bounded by RESULT_ROW_LIMIT), or None when the plan's own LIMIT was smaller.
truncated bool no True when the result has row_cap rows: it was cut at the limit and may be missing rows. A result of exactly row_cap rows may be whole; it is counted as cut.

RowSample

name type required meaning
columns List[str] no Column names, in result order.
rows List[List[Any]] no At most sample_rows rows (default 50).
total_rows int no Row count of the whole result, not of the sample.

State mapping

result_from_state normalises the raw graph state (LangGraph may return dicts or model instances for nested values):

QueryResult field Graph state source
sub_queries[].sql subgraph_outputs[<id>].sql_draft
sub_queries[].id / .intent / .datasource_id / .schema_version subgraph_outputs[<id>].sub_query
sub_queries[].plan / .validation / .status / .retry_count / .plan_source / .row_cap / .truncated subgraph_outputs[<id>]
usage.plan_cache_hits count of sub_queries[] with plan_source == "cache"
sub_queries[].rows artifact_store.read_result_frame(subgraph_outputs[<id>].artifact)
final_answer answer_synthesizer_response.final_answer
source verified_match (set by the curated matcher on a verified answer; absent means generated)
errors, reasoning, warnings, trace_id, artifact_refs, timings, usage top-level state

errors are projected onto a client-safe summary; stack_trace and details are deliberately not exposed.

result_from_state(state, artifact_store=None, sample_rows=50) reads rows only when an artifact store is supplied. A failed read is not fatal: rows stays None and a warning is appended.

timings is collected by NodeTimingCallback (packages/nl2sql/src/nl2sql/pipeline/timing.py), a LangChain callback handler that run_with_graph always attaches. It keys on metadata["langgraph_node"] where LangGraph provides it, so the keys are node names such as ast_planner, logical_validator, generator and executor.

Usage: tokens, calls and model time

usage is collected by TokenUsageCallback (packages/nl2sql/src/nl2sql/services/callbacks/token_handler.py), which run_with_graph attaches to every run, so the Python API, the REST API, the CLI and the demo playground all report it. It is also filled in, with whatever had been recorded, on a timed-out or cancelled run.

QuestionUsage:

name type meaning
total UsageTotals The whole question.
nodes Dict[str, UsageTotals] Keyed by graph node: datasource_resolver (the answerability check), decomposer, ast_planner, refiner, answer_synthesizer.
calls List[LLMCallUsage] Every model call in order, with its node and model.
plan_cache_hits int Sub-queries whose plan came from the plan cache. A hit makes no planner call, so it adds no ast_planner calls or tokens; a question answered entirely from the cache has no ast_planner entry in nodes.

UsageTotals:

name type meaning
calls int LLM calls. The planner and refiner run once per retry, so a sub-query retried once shows ast_planner.calls == 2.
input_tokens Optional[int] Prompt tokens, including cached ones.
cached_input_tokens Optional[int] Input tokens served from the provider's prompt cache (a subset of input_tokens).
cache_write_input_tokens Optional[int] Input tokens written to the prompt cache (Anthropic reports these; OpenAI does not, so it is null there).
output_tokens Optional[int] Completion tokens, including reasoning ones.
reasoning_tokens Optional[int] Reasoning ("thinking") tokens (a subset of output_tokens).
total_tokens Optional[int] As the provider reports it; input + output if it does not.
latency_s float Seconds spent waiting on the model, summed over calls. The node's wall-clock time, which includes its non-LLM work, is in timings.
cost Optional[float] Dollars, when every call counted here has a cost; otherwise null. Each call is priced from llm.prices, then the dated price table: cache reads at cached_input, cache writes at cache_write (Claude's are 1.25x input), the rest of input_tokens at input and output_tokens at output; see Prices.

A token count in UsageTotals is the sum of what the calls reported, and null when no call reported it: an unknown count is never 0.

LLMCallUsage has the same token fields plus node, model (the model the provider says served the call, e.g. gpt-4o-2024-08-06), latency_s, cost, usage_reported and error.

Tokens are read by the adapter of the wire type that served the call (read_usage in nl2sql/llm/wires/openai.py or anthropic.py), picked by the model_provider the client stamps on the response; TokenUsageCallback only asks it. Both read LangChain's AIMessage.usage_metadata (input_token_details.cache_read, input_token_details.cache_creation, output_token_details.reasoning) into the same fields, and differ where their providers differ, as below. A detail the provider did not report is null, never 0. A call whose result carried no usage at all is recorded with every count null and usage_reported: false; a failed call is recorded the same way, with its latency and error.

For OpenAI through langchain-openai (verified against the pinned 1.6.x by packages/nl2sql/tests/unit/test_fake_llm_server.py): prompt_tokens, completion_tokens, total_tokens, prompt_tokens_details.cached_tokens and completion_tokens_details.reasoning_tokens all arrive. OpenAI's chat completions API reports no cache-write count, so cache_write_input_tokens is null there.

For Claude through langchain-anthropic (provider: anthropic; verified against the fake Anthropic endpoint by packages/nl2sql/tests/unit/test_llm_wire_contract.py, which runs the same usage tests against both wires), Anthropic's usage maps as follows:

Anthropic usage field here
cache_read_input_tokens cached_input_tokens
cache_creation_input_tokens, or its per-TTL split cache_creation.ephemeral_5m_input_tokens + ephemeral_1h_input_tokens cache_write_input_tokens
input_tokens + cache_read_input_tokens + cache_creation_input_tokens input_tokens
output_tokens output_tokens

Anthropic's own input_tokens counts only the uncached tail of the prompt; langchain-anthropic adds the cache reads and writes back, so input_tokens here is the whole prompt, counted once, and both cache fields stay subsets of it. When the write is reported per TTL, langchain-anthropic sets the generic cache_creation detail to 0 and puts the tokens under the TTL keys, which are summed here.

Cost is (input - cached - cache_write) * input_price + cached * cached_input_price + cache_write * cache_write_price + output * output_price, per million tokens; a cache count the provider did not report is not split out of input. Prices are looked up in llm.prices by the served model name, then by the configured one, and only then in the dated table by either, by exact match only, so a gpt-4o price never prices gpt-4o-mini. A call with no price, or no input or output count, has a null cost. See Prices.