CLI¶
One entry point: dst (services/cli/main.py). Two kinds of command:
- In-process —
init,dev,serve,migrate,bootstrap,secret,demo,test,revoke-key,reindex,evals migrate,import dbtrun directly against the configured database or the local files; no server, URL, or token needed. - Remote — everything else is an HTTP wrapper over the management API.
Explicit
--url/--tokenflags win; otherwiseDST_URLandDST_ADMIN_TOKENresolve from the process env or./.env—dst initanddst bootstrapwrite them, so a scaffolded project needs no flags.
dst --version prints the installed version.
Color: output is colored only on a TTY, and the plain bytes are identical to the
colored run's — piped output is the parse contract. dst --no-color … (or the
NO_COLOR / DST_NO_COLOR env vars) turns it off everywhere.
Project lifecycle¶
dst init [dir]¶
Scaffold a new project (dbt-style; see Project files).
Bare dst init creates ./<name> — it never scaffolds into the cwd implicitly;
dst init . is the explicit way to use the current directory. Interactive by
default; every prompt has a flag for headless runs (services/cli/init.py).
Flags: --name, --warehouse demo|postgres|bigquery|snowflake|none,
--example/--no-example (the bundled DuckDB example lens; default yes),
--db-port (default 5432), --api-port (default 8000, written to .env as
DST_URL), --yes (accept defaults, no prompts).
dst dev¶
Postgres up + migrate + serve with auto-reload, one command. A reachable database is
used as-is; otherwise the project's docker-compose.yml is brought up and waited on by
connecting. Flags: --host (default 127.0.0.1), --port (default: the port in
DST_URL, else 8000).
dst serve¶
Run the API via uvicorn; serves the built dashboard same-origin when present (source
checkout's apps/web/dist or the wheel's bundled copy). Without one it says so and
serves the API only. Flags: --host, --port, --reload.
Refuses to start on a schema behind this build, before it even probes the port: a
server on an out-of-date schema serves answers correctly and loses every trace in
silence, because the request_log writes fail in a background task nobody can see. The
message names the current revision, the one this build needs, and the unapplied list,
then tells you to run dst migrate; exit is 1 and no server starts. A schema
ahead of this build starts normally — older code on a newer schema is the safe deploy
order — and so does one whose state cannot be read, because a database still coming up is
not a broken one. Full recovery path: Upgrading.
dst migrate¶
Run database migrations to head against DATABASE_ADMIN_URL. Idempotent, and it takes a
blocking advisory lock so concurrent runs serialize instead of racing.
Says precisely what it did, so an upgrade confirms itself:
migrated 0037 → 0040 — 3 revisions applied (0038, 0039, 0040)
already at head (0040) — nothing to apply
schema created at 0040
One of those three, depending on where the database started — migrated to head used to
print identically after applying three revisions and after applying none.
dst doctor¶
Can this install actually run? /ready reports what is configured — deliberately, and
it never gates on models — so a fully-configured but uncallable provider (SDK
incompatibility, bad key, wrong base_url) used to be discoverable only via a real
query's 500 and a server-log traceback. doctor checks callability: DB schema state,
the embedder, and one cheap real call per model tier (max_tokens: 1 — a fraction
of a cent) with the failure printed verbatim per tier:
db ok (0058)
embeddings ok
providers
fast claude-haiku-4-5 ok
smart claude-sonnet-4-6 FAIL — anthropic: SDK call signature mismatch (…)
Exit 0 when every check passes, 1 otherwise. Warehouse connections are not re-probed
here — every dst apply already probes them and prints the capability line. Flags:
--dir (default .).
dst bootstrap¶
Create (or reuse) an org and mint a fresh admin token; idempotent — rerunning never
creates a duplicate org, only a new token. Saves the token to ./.env as
DST_ADMIN_TOKEN when that file exists. Talks to the database directly — no server
needed. Flags: --org (default default), --email (create/update the first
dashboard admin), --password (omit to be prompted).
dst secret¶
Generate a DST_SECRET_KEY (Fernet) for encrypting stored credentials.
dst rotate-key¶
Re-encrypt every stored credential under a new DST_SECRET_KEY. The variable
takes a comma-separated list: the first key encrypts, all of them decrypt. That
is what makes rotation possible without downtime.
DST_SECRET_KEY=<new>,<old> # 1. deploy — everything still decrypts
dst rotate-key # 2. move every secret onto <new>
DST_SECRET_KEY=<new> # 3. drop <old>
Refuses to run with only one key configured (override with --force to re-encrypt
in place). That is not pedantry: with just <new> set, nothing encrypted under
<old> can be read, so the rotation would skip every row it could not decrypt and
still report success. Any row that fails is named individually and the verb exits
non-zero — do not drop the old key until it exits 0.
The server verifies the key at startup against an encrypted sentinel row and refuses to boot on a mismatch, so a wrong key is a failed deploy rather than a 503 on whichever connector is touched first.
dst demo¶
Publish the bundled DuckDB demo lens into an org. Flag: --org-id (required; the UUID
dst bootstrap prints).
Files → server¶
dst plan¶
Dry run of the project directory against the server. The default output is the
terraform-shaped summary: one glyph row per asset that needs attention (+ create,
~ update, ✗ invalid, ! stale), then a counts line — Plan: 1 to add, 2 to
change, 1 invalid, 11 unchanged. --full prints the per-path diffs (the pre-2026-08
layout) for review flows; --json prints the parseable row list, including the
scope: warehouse entry the human forms stay quiet about. Shared-asset edits mark
their selecting lenses stale either way (they recompile on apply). Flags: --full,
--json, --dir (default .), --timeout (seconds, default 120).
Plan predicts apply: every semantic/** file is validated through the same seam
apply parses with, so a file apply would reject plans as invalid on its own
row — all of them, not just the first — and plan exits 1. A clean plan
exits 0.
Plan also says what it cannot predict. Every run prints a not checked by plan
block: warehouse connection probes, eval-case expected_sql actually executing,
and the publish eval gate all need a live warehouse and happen only at apply —
so a green plan is not a promise that apply succeeds. And a
drift: '<connection>' UNARMED footer means no committed profile exists yet, so
drift detection has no baseline to compare against — dst probe arms it.
dst apply¶
Deploy the project directory; files win. Blue/green and all-or-nothing: any error
aborts the whole apply (APPLY ABORTED, exit 1) and prior versions keep serving;
connection declarations are probed before landing. Concurrent applies conflict (409).
Exit is non-zero on any error, not just rejected lenses. Every stage — connections,
semantic assets, eval cases, certified answers, the lens publish — shares one
transaction, so a failure deploys nothing and the error line says so.
The default output is a grouped report — one section per scope/lens, warnings and
errors in place, a per-connection capability line (read ✓ · query ✓ · query
history ✗ …) for every connection the apply probed, ending in a Apply complete.
counts line. --json prints the server's raw row array (the pre-2026-08 default) —
the machine shape for agents and scripts.
Flags: --json, --dir, --timeout (seconds, default 300), --probe-certified
(execute each new certified answer once, read-only and row-capped, to record its
verified value — new entries only: already-stored answers are not re-probed, and
probing zero says so; re-author the sql or run dst test for a sweep. Opt-in,
costs one warehouse query per answer; a probe failure warns and stores the answer
anyway), --require-gates (fail closed: abort, exit non-zero, if any lens configured
for an eval gate had it skipped — empty suite, provider error, or unreachable
warehouse alike; by default a skipped gate publishes with a warning. CI wants this
flag: without it a provider outage silently converts a gated apply into an ungated
one that exits 0).
Lenses whose managed files match the server exactly report unchanged and skip the
publish path — no recompile, no version bump, no eval-gate generation — so a no-op
apply is cheap and apply; plan converges to zero changes. The footer adds one gate
line (eval gates: 2 passed, 40 skipped (…)) whenever any lens was gated.
A timed-out apply is still running
The handler is synchronous: a client disconnect cannot cancel it. If the CLI
times out, the server still holds the org apply lock and will commit when it
finishes — the message says so. Poll dst plan for committed state rather
than re-applying (a second apply gets 409 while the lock is held). The first
certified answer an org ever applies pays the embedder's cold start; raise
--timeout for it. A 502/503/504 from a proxy is the same situation — upstream
never answered, so that family gets the in-flight message, not the rollback one.
dst export¶
Write server-side lenses into the project directory — the adoption path for lenses that
predate the files. Prints a connections: snippet to merge into dst.yaml by
hand; never auto-writes it, and secrets never leave the server. Flags: --lens <name>
(repeatable; default: everything), --dir.
dst introspect¶
Print a connection's schema, agent-legible — the raw material for authoring
semantic/ files. Flags: --connection (required), --tables a,b (subset),
--profile, --json.
--profile adds the facts a schema cannot give you — enum values, null rates,
ranges — by running the catalog + sampling passes against the warehouse right
there. The reads are row-capped and PII-safe (PII-named columns yield shape
only, never literals), but it is one sampling pass per table in scope: narrow it
with --tables on a wide warehouse. Without --profile the listing is schema
only and says NOT PROFILED at the top, so a bare schema is never mistaken for
a complete answer. The dst.yaml path is the one that samples; when the
command falls back to the server, the facts are whatever that connection's
profiling passes already stored.
Every non-system schema is searched, and names come back qualified
(spider.player); scope the connection with schema: <name> — or, on BigQuery,
datasets: [a, b, c] — under its config in dst.yaml. On a warehouse too wide
to list fully the unscoped listing is capped and says TRUNCATED at the top;
--tables is unaffected by the cap — it resolves the named tables against the
FULL catalog, at any qualification depth (table, dataset.table,
project.dataset.table). One column per line, - <name>: <type> (<warehouse
type>) — copy the first type, which is the value fields[].type takes. Use
--json for anything that parses rather than reads: no prose separator survives
a warehouse that can put , inside a type and a space inside a name. A
connection that yields no table is an ERROR, not a blank line: the message names
the schemas searched and the command exits non-zero.
dst probe¶
Record the warehouse's full profile into the project, at
profiles/<connection>.probe.json. introspect --profile prints those facts to
your terminal, where they help you author and are then gone; probe writes them
down — the same catalog and sampling passes plus partitions and freshness,
crossed with the entities that read each table. Flags: --connection (default:
every warehouse connection dst.yaml declares), --tables a,b,
--sample-all, --dir.
Commit the artifact. The next dst apply ingests it, and the value
dictionaries land in the serving prompt — so generation filters on the literals
the warehouse actually holds. A column storing 'FI' is the difference between
an answer and this:
"customers in Denmark and Finland" →
WHERE country IN ('Denmark','Finland')→ zero rows, reported as "there are no customers in Denmark or Finland."
Nothing errored, so nothing warned. A filter written from the question's
vocabulary instead of the column's returns a confident, empty, wrong answer, and
a committed value dictionary is what stops it. Describing the domain in a
field's description does the same job for facts you know; probe is for the
ones the warehouse knows and you have not written down.
Sampling covers the tables the semantic layer reads — everything, while the
layer is still empty. --sample-all samples every table (one capped read each:
the expensive form on a wide warehouse) and --tables pins an exact list; the
catalog pass records every table either way. Re-run it whenever the warehouse
moves — a nightly cron is the intended cadence — because a value dictionary is
only as true as its last pass.
Ingestion is advisory, never governance state: a malformed or connection-less
artifact warns and skips, a stored profile newer than the incoming one is kept
per table (a server refreshed over REST outranks an old commit), and absence
never deletes. The drift baseline lives in the same directory and is never
swallowed.
dst drift¶
What has the warehouse done since you profiled it, and does the semantic layer
still match. introspect is a snapshot; this is the diff — new, dropped, and
retyped columns since the profile committed at profiles/<connection>.json, and
crucially the cross-reference: a new column on a table a definition or entity
reads is flagged with the asset that reads it. This is how the layer avoids
silently serving a stale derivation after the warehouse grew the real column —
e.g. "orders gained discount_amount; definition discount derives it from
list_price − unit_price — review whether the new column supersedes the
derivation." Findings the layer reads sort first, definition-backed ahead of
entity-only. --json; --accept re-records the baseline once you have reviewed.
PII-marked columns report schema deltas but never values.
dst plan runs a cheap version of this every time: when the warehouse has
changed since profiling it prints one line pointing you here, and it never
touches the warehouse when no baseline exists or the connection is down (the
degradation is visible only under plan --json). Flags: --connection
(required), --accept, --dir, --json.
dst sql <sql>¶
Run read-only SQL and see the rows — the governed version of opening a warehouse
client. introspect says what the columns are; it cannot say what they contain
together, and deciding a business rule ("is a refund a negative amount, or a row with
status='refunded'?") takes five actual rows. Flags: --connection or --lens
(exactly one, required), --limit (default 20, max 500), --json, --dir,
--url/--token/--key.
dst sql "SELECT order_id, status, amount FROM orders" --connection warehouse --limit 5
Guarantees, all of them the reason to use this instead of a warehouse client: one
SELECT statement, no DML/DDL anywhere (runtime/sql_guard.py, the same guard generated
SQL passes), row-capped with truncated said out loud when the cap fires, and logged
to request_log — so the probe behind an authoring decision sits in the audit trail
next to the answers that decision shaped.
--connection <name> probes the whole connection and needs an admin token: a connection
is an org-level credential, not a caller-scoped grant. It requires the connection to be
applied (this verb runs server-side; introspect reads dst.yaml directly and works
before the first apply). --lens <name> probes inside one lens's allow-list and works
with a dst_ caller key — every table and column checked against the compiled model,
and SELECT * refused there, because a star is how a column the lens does not expose
would come back. Mirrored as the sql MCP tool.
It complements introspect --profile rather than repeating it: per-column enum values,
null rates and ranges come from the profile in one pass, so ask it for those. Reach here
for rows, cross-column facts, and join checks a profile cannot see.
dst import dbt¶
One-shot import: dbt artifacts → dst-owned semantic/ files, with a coverage
report. Never re-synced — the files are yours afterwards. Flags: --target-dir (the
dbt target/ dir holding manifest.json + semantic_manifest.json, required),
--connection (the dst connection the tables live on, required), --dir.
Ask and verify¶
dst define <term>¶
Print what a governed term means, verbatim — the sibling of dst query.
query answers questions about data (governed SQL, a warehouse execution);
define returns the approved definition and nothing else: no generation, no
warehouse, nothing billed. Flags: --json (the full DefinitionLookup),
--timeout, --dir, --url/--token, --key.
--key dst_… looks up as that caller, so you see only the terms from lenses
that key may use — the same scoping query obeys. Exit 1 when nothing is
governed, so an agent branches on the code instead of parsing prose: "we have
no approved meaning for this word" is a distinct answer from any definition.
Deliberately an index lookup rather than a search: top-k retrieval over a governed vocabulary can miss precisely the load-bearing terms, and a definition surface that quietly returns a near-miss is worse than one that says it has nothing.
dst query <lens> <question>¶
Ask a governed question from the terminal — the verify step of the authoring loop.
Prints the answer, the SQL, the confidence line, and the request_id (dst correct
takes that and nothing else); a clarification prints as clarify: with its options.
Flags: --json (the full QueryResponse), --timeout (default 180s), --dir,
--url/--token, --key.
--key dst_… asks as that caller instead of as the admin, and it is the only way to
verify an access grant: an admin token bypasses every lens allow-list, so "I granted B
access — does it work?" answers 200 whether the grant landed or not. Grant → ask with
B's key (expect the answer) → ask with C's key (expect exit 1 and a 403). Resolves from
DST_API_KEY when the flag is absent; an explicit --key beats the admin token.
Exit codes carry the outcome, so a script can branch without parsing prose: 0 the
question was answered; 3 the lens declined — a refusal or a clarify: — which is a
governed outcome to act on, not a failure; 1 it broke (a bad request, no server, a
guard rejection). A refusal is deliberately not code 1: "I will not answer that" is the
product working. --json carries the same distinction as status.
dst lens prompt <lens> <question>¶
Show exactly what the model sees for a question — the assembled system prompt, the prose
context, and, per authored asset, whether it reached the prompt or was dropped (not
selected, trimmed for budget, or escalation-only). No LLM or warehouse call. This is how
you catch an authored definition or dimension that validates and applies but never
reaches the model: if it isn't in this output, the model can't use it. --json for the
full structure.
dst test [lens]¶
The certified corpus as the regression suite: for each active certified answer, execute
its stored SQL (the oracle) and run its question through generation with certified
matching disabled, then compare executed results. Approved behavioral expectations
(expect: clarify|refuse) run alongside, scored on response shape. Default: every
published lens (--all says it explicitly). A passing answer re-stamps its bindings to
current hashes — re-verification through evidence. Runs in-process; needs a smart-tier
model configured.
Exit codes carry the outcome, so a deploy gate branches without parsing prose: 0 everything verified passed; 1 something diverged; 4 nothing was verified — the lens has no certified answers and no eval cases, so the run could not have failed. Treat 4 as not-green.
That third code exists because 0/0 passed and 10/10 passed were the same exit
code, which makes a green light over an empty suite indistinguishable from real
assurance: a team can run the verb daily, watch it go green every time, and be
verifying nothing at all while a metric silently breaks across an apply.
A gate that cannot fail is not a gate.
Each case prints as a ledger row — PASS/FAIL, org/lens: question, the divergence
when there is one, and the case's wall-clock — then a rule and the N/M passed
summary. --json emits the same rows structurally (org, lens, question, verdict,
certified/generated result, reason, elapsed_s) plus the summary, for CI that wants
the table rather than the exit code alone.
Flags: --all, --json, --dir (default .) — the project whose .env supplies
DATABASE_URL and the provider keys, so CI and cron can point the sweep at a project
from outside its directory. --tag (repeatable, any-match) runs only behavioral cases
carrying that tag — --tag intent:discriminator scores the routing slice on its own;
certified answers carry no tags, so a tagged run is a case slice. --url/--token
are accepted for uniformity and ignored (this verb talks to the database, not to a
server); passing them prints a note saying so.
dst evals migrate¶
Local file rewrite, no server: every value-shaped eval case (has expected_sql)
becomes a certified_answers.yaml entry; cases.yaml keeps only behavioral entries.
Value cases are not scored anywhere — certified answers are the regression suite.
Nothing lands until you review and dst apply. Flag: --dir.
Governance¶
dst keys create --caller <name> / dst keys list¶
Mint a caller and their API key (shown once), or list callers. One key per person — never per tool.
dst revoke-key --caller <name>¶
Revoke the caller's active keys. In-process, immediate. Scoped to one org — pass
--org, or run from a project whose .env carries DST_ADMIN_TOKEN. Caller names
repeat across tenants, so this verb refuses to act rather than guess.
dst revoke-token <raw>¶
For leaks. Kill one credential when what you have is the credential itself — the
string in the committed file, the CI log, the screenshot. Takes any kind: dstadm_ admin
token, dst_ caller key, dsto_ OAuth token. You do not need to know which store it
lives in, which caller it belongs to, or which org.
dst revoke-token dstadm_...
revoked 1 admin_token in org 'acme'
Three outcomes, deliberately distinct — during containment they mean different things:
| Output | Exit | Means |
|---|---|---|
revoked 1 <table> in org '<name>' |
0 | Done. The org is named so you can see which tenant you touched. |
already revoked (<table>) |
0 | Someone got there first. Safe to re-run. |
no such credential in this database |
1 | Not contained — you are pointed at the wrong deployment. Check DATABASE_URL. |
Unlike revoke-key this needs no --org: a token hash is unique across the database,
so there is exactly one row it can touch. Revocation takes effect on the next request —
there is no caching window.
dst correct <request_id>¶
File a correction against a served answer — step 3 of the correction loop, and the
flywheel's entry point. Opens the review ticket dst patches draft drafts the fix
from. Flags: --kind definition|scope|number|freshness|other (required — the drafter
routes on it), --target (required — the term the correction is about, used verbatim;
without it placement is vocabulary matching, which mistargets), --note/--note-file,
--corrected-sql, --corrected-answer, --json, --timeout, --dir,
--url/--token, --key.
--key dst_… files as that caller, which is the posture of the person who actually
found the wrong answer: a business user holds a caller key and no admin token. Resolves
from DST_API_KEY when no admin token is in scope; an explicit --key beats the
admin token, and an ambient admin token otherwise wins (so an analyst holding both keeps
filing org-wide).
Scope. A caller key may correct only its own requests — the ones it asked. Filing
against another caller's request_id is a 403, and so is reading their ticket. An admin
token files against anyone's, which is what lets the data team triage. Enforced
server-side on /v1/reviews, not in the CLI.
dst observe¶
Who has been using this layer, and what for. Read-only, admin-authed, four shapes:
dst observe headline + usage per caller
dst observe callers who, how many, how much, how many errors
dst observe requests what they actually asked
dst observe show <req_id> one request: question, SQL, confidence
Flags: --lens, --status ok|refused|error (with requests), --limit (max
200), --json, --timeout, --dir, --url/--token.
The bare form is the answer to "the CFO wants to know who has been using the
reporting tool": one line of totals, then a row per caller. requests --status
error is the fastest way to find a metric that is failing repeatedly — a caller
retrying the same question three times in two minutes is visible here and
nowhere else.
This existed on the API and the dashboard and nowhere on the CLI. That gap was not cosmetic: a data team works in a terminal, and so does every agent, so attribution reachable only from a browser is unreachable by the callers most likely to need it.
dst reviews¶
List the review queue, all states and origins. Flags: --state
open|ai_review|needs_human|approved|changes|rejected, --origin ai|human (ai =
auto-flagged by a lens's auto_review policy), --json (machine-readable,
unfiltered by default), --watch (poll needs_human, print each new ticket once with
its question; Ctrl-C stops), --interval (watch poll seconds, default 30), --dir,
--url/--token, --key.
With --key dst_… (or a project whose .env holds only DST_API_KEY) this reads
the caller-scoped list instead: the tickets on your own requests, and their state —
the other half of the loop for whoever filed the correction. The whole-queue view stays
admin-only, as does --watch.
dst rule <ticket_id>¶
Rule on a review ticket. Flags: --verdict approve|changes|reject (required),
--reasoning, --certify (after an approve, promote the request's question→SQL to a
certified answer in the same act; requires --verdict approve). See
The correction loop.
dst patches list --lens <name> / dst patches approve <id>¶
The self-healing loop's ruling from the repo: approving a definition/skill patch writes
the server's proposed file into the working tree — review it with git diff, land
it with dst apply; nothing is live until you do. Flags: --status
candidate|approved|rejected (list), --json, --dir.
dst lens list¶
The deployed lenses, from the server — name, status, shape (entities/definitions),
query volume, and origin. Files and server state diverge by design (file absence never
deletes; API- and cloud-born lenses are server-only until adopted), and this is the CLI
read path for that: a lens marked not in files is the adoption cue —
dst export --lens <name> brings it under the project. --json for the raw rows.
dst lens log <name>¶
The lens's published history as a change log, newest first: version, date, who
published it (a dashboard user, an admin token by label, or a server process such as a
recompile), and the summary the publish recorded. This answers "what changed on this
lens and who did it" in the terminal where the apply just ran; the dashboard's History
panel shows the same trail with diffs. --json for the raw rows.
dst lens rm <name>¶
Delete a lens on the server. Prints the cascade first (versions, certified answers,
eval cases, context chunks, patch candidates — the request log and rulings survive as
history) and asks; --yes for headless. File absence never deletes — this verb is the
only way.
dst semantic rm entity|definition <name>¶
Remove a shared semantic asset. The server refuses while any published lens still selects it, and names the lenses.
dst connection rm <name>¶
Delete a server-side connection (its stored credentials go with it). Dependent
lenses are checked first and the verb refuses while any still reads through the
connection — re-point or dst lens rm them before retrying; --yes for headless.
File absence never deletes: a connection dst.yaml still declares is re-created on
the next apply, so remove the declaration too when you mean gone.
Maintenance¶
dst reindex¶
Re-embed all stored vectors with the configured embedder — required after changing the
embedding model or dimension (the write-path guard blocks mismatched writes until
then). Resumable. Flag: --batch (rows per committed batch, default 64).
(Every command, flag, and default verified against services/cli/main.py and
services/cli/init.py.)