Explore
SkillDatabases & dataUse this whenever you need to know what is actually in a database, warehouse, or DuckDB file before you trust it: ranked inventory of what exists, column profiles, PII detection, grain and data-quality problems, verified join inference, Mermaid ER diagrams, guarded ad-hoc SQL probes, k-means segmentation, and reading the semantic layer a repo declares (dbt semantic models, a hosted dbt Cloud layer, or native Apache Ossie documents), producing a draft map without dumping the whole schema into context. Trigger it on an unmet precondition, not on any particular phrasing: if you are about to write or fix SQL against tables whose columns, types, grain, or join keys you have not verified in this session, use this FIRST. That includes dbt work: building a staging or mart model, fixing a broken model, or debugging wrong numbers, whenever the ticket names source tables without spelling out their schema. It also applies mid-task: if you are partway through and hit a table you have not inspected, stop and use this rather than guessing column names or firing off one-off SELECTs. Also use it for direct questions like "what''s in my duckdb", "which tables matter", "how do these tables relate", "is this data any good", "any PII in here", "how many orders have no customer", "cluster my customers", or "what metrics does this semantic layer define". Explore is read-only and writes nothing but the .dex/ cache. It does not author the model: pair it with transform, which writes the change once you know what you are writing against. To reconcile a project that has fallen out of sync, use maintain.
Use Explore in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Explore and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Explore skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by exmergo/dex in skills/explore/SKILL.md and read by ahel’s review.
Make sense of a warehouse or a local DuckDB database the way an analytics engineer does: rank what matters, drill selectively, and persist a draft map. This is the flagship, fully read-only skill. It absorbs profiling and relationship inference as capabilities; they are not separate skills.
How to drive it
Run the engine through the wrapper. It prints one sanitized JSON envelope and nothing else; read the envelope and decide the next step.
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" <subcommand> [flags]
dex runs its engine through uv, which is a prerequisite and is not installed by
Claude Code. If the shell reports uv: command not found, stop and tell the user
to install it (curl -LsSf https://astral.sh/uv/install.sh | sh, or
brew install uv, or pipx install uv), then re-run. Never fall back to raw
Python, pip, or a database CLI to do the work another way: the guardrails live in
the engine, so any other path is unguarded.
The first command in a fresh environment installs the engine, so it can take tens
of seconds where later ones take well under a second. --warm pays that install up
front and exits without running anything:
uv run --no-project --script "${CLAUDE_SKILL_DIR}/scripts/run.py" --warm
Offer it once at setup. It is not something to run before an ordinary command.
If the user has no warehouse to point at and wants to see what dex does, demo
generates one: a seeded local DuckDB warehouse plus the .dex/config.yml for it,
with no credentials and no network, so every subcommand below then runs with no
flags. It only ever creates, so it refuses rather than touch a file that already
exists. Offer it rather than assuming it: a user who does have a warehouse wants
that one read, not a fixture built beside it.
Subcommands, in the usual order:
-
connect test --path <file.duckdb>confirms a read-only connection and reports capabilities. -
explore inventory --rankreturns a ranked object summary (counts and sizes, never rows). -
explore profile <objects>(space- or comma-separated) returns column profiles, PII flags recorded as (column, category, confidence) and never example values, plus ranked candidate keys, the likely grain,key_evidence, and data-quality warnings (e.g. an id unique on all but 110 rows, which will fan out on joins).candidate_keysis ordered, tightest proven key first, andkey_evidencegives one entry per combination considered with itsstatus(reportedorsuppressed) and the reason. Read it before you trust a composite: a combination unique only because one member is unique on almost every row, or because a money column completes it, is suppressed rather than reported. Where a near-unique column is the real story the warning says so with the ratio, the counts, and how many rows would have to be removed for it to be unique. That last number is the one to act on: it names a source defect to fix rather than a key to work around. A generic*_nameflag's confidence is refined by value-shape evidence from the same scan, in both directions: person-shaped values corroborate it, a closed reference vocabulary or long labels de-rate it below the firewall's blocking threshold, and missing evidence changes nothing (the flag itself is never removed). Distinct counts are approximate for scale, but any column that looks unique within approximation noise is escalated to an exact COUNT(DISTINCT) (distinct_count_exact: true), so uniqueness and grain verdicts rest on proof; a~prefix marks a number that is still approximate, on a count and on a percentage alike, so a figure quoted without one is exact arithmetic over an exact distinct count on a column with no nulls. A requested object whose cached profile is still fresh (same connector, schema unchanged, withinprofile_freshness_hours, default 24) is served from the cache (cache_hit_count) instead of re-scanned, so profiling a tablemapjust wrote costs nothing to spend; pass--refreshto force a re-scan when the source changed in a way the free metadata check cannot see. -
explore relationshipsreturns inferred and declared joins with confidences, plus notes explaining what the inference examined (so an empty list is meaningful). Add--verifyto measure each inferred join with an aggregate overlap probe (orphan fraction, confidence adjusted). A declared join has two sources: arelationshipstest, and (with--use-project) an entity two semantic models share, which the layer states outright with the key named per model.declared_byon an edge names that entity,semantic_join_countsays how many came that way, and the notes call out the ones name-based inference did not find, which is the interesting set: a semantic layer routinely joins columns that share no name at all. -
explore mapwrites or updates the.dex/cache and returns the map (--verifyworks here too). Alongside the counts,data.objectsgives each top-ranked object its row count, detected grain, best-ranked candidate key, notable columns (each carrying the role that earned it a place:grain,key,join, or a PII flag) and data-quality findings, anddata.edgesgives the join edges in the same shapeexplore relationshipsreturns. With--use-projecteach object also carriessemantic_models, the semantic models that sit on that relation, which is what separates a load-bearing table from a merely large one: empty means nothing in the layer reads it. Read that payload instead of chainingprofileandrelationshipsto re-derive it; go to those two when you need one object in full, or a value domain, whichmapnever carries. It is budgeted: 25 objects by rank, 12 columns per object, 40 edges, 5 findings per object. Every cap binds in every mode and every elision is counted innotesand in anelided_*field, so an emptynotesmeans nothing was cut.--detailwidens the selection to every column and to objects that were inventoried but never profiled, and lifts no cap; it spends nothing, unlike--full. Past 50 objects it profiles only the top 25 by rank and says so innotes(withskipped_count); pass--fullto profile everything. On a re-map, objects skipped this run keep their prior profiles (carried_forward_count), each stamped with its ownprofiled_atso staleness is visible instead of column detail silently vanishing. A selected object whose cached profile is still fresh (same connector, schema unchanged, profiled withinprofile_freshness_hours, default 24) is reused without a re-scan (cache_hit_count), so re-runs cost nothing to spend; pass--refreshto force a full re-profile when the source changed in a way the free metadata check cannot see (e.g. rows changed but the schema did not).explore relationshipsand the standaloneexplore profilereuse fresh profiles the same way. -
explore diagram [--full]renders the cached map as a Mermaid ER diagram indata.mermaid. Free and connectionless (it reads the cache, never the warehouse), so it is safe to re-run while shaping the picture. Reproduce the string verbatim in a fenced ```mermaid block so the human can see it, and write it to a.mmdor a markdown file when they want one on disk: the engine deliberately writes no file. Never redraw or "tidy up" the diagram by hand. The glyphs are claims the engine derived from evidence, and a plausible-looking cardinality you supplied is exactly the overclaim this command exists to prevent: declared joins are solid, inferred dotted, and an unverified inference never says "exactly one". A solid line labelled with a semantic entity is a join the semantic layer declares; look the entity up withexplore semantic list. Readnotesbefore presenting it, since it states any object or column that was left out;--fullwidens from the default (profiled, joined objects and their grain, key, join, and PII columns) to everything eligible. -
explore query "<SELECT ...>" ["<SELECT ...>" ...]answers ad-hoc questions the fixed commands don't cover: you write the SQL, the engine's query firewall refuses or bounds it. Pass a statement per argument, or--sql-file <path>for a longer list, and ask a whole chain of questions in one call rather than one call each; each statement is judged and answered on its own, so a refusal on one does not cost you the others, anddata.resultscarries one entry per statement. A table you have not profiled, including a model you just built, is profiled for you and the statement then runs, so probing something new is one call rather than three; the envelope says what it profiled, and on a metered connector that profile is priced into the same confirmation as the statements. Results come back row-major and capped; a refusal names the offending column and the fix, so one rewrite is enough. Read${CLAUDE_SKILL_DIR}/references/probe-playbook.mdbefore writing a probe: it maps common questions to effective probe shapes. -
explore cluster <object> [--features a,b,c] [-k N]runs k-means over a bounded sample of the object's numeric columns and returns the segment structure: per-cluster sizes and fractions, centroids (each coordinate is a cluster's mean of that feature, an aggregate), the silhouette score, and, when-kis omitted, the k it picked plus the silhouette sweep it chose from. Requires the.dex/cache (runmap/profilefirst) so features can be auto-selected from profiled numeric, non-PII, non-key columns; pass--featuresto choose them yourself (naming a PII column, or a key, opts it in deliberately, and only its mean is ever reported). A key is never a feature: its mean is meaningless, and a fact table is mostly keys plus a handful of measures, so clustering on them just partitions surrogate ranges. Keys are the unique columns, the columns that join out (from the joinsmapinferred), and the columns named like one; prefermapover a bareprofilehere, because without inferred joins a foreign key is caught only if its name gives it away. The notes name every excluded column, so check them before trusting a result. Two things the silhouette alone will not tell you, both of which the notes will. A cluster holding under 1% of the sample is an outlier pocket, not a segment, and it pushes the score up precisely because it sits so far out: report that as outlier detection, or re-run with-kto split the bulk. And on connectors that cannot seed a sample the draw changes per run, so two runs can disagree on k; the envelope'ssample_repeatablesays which case you are in, and comparing runs across different draws is meaningless. Only aggregates cross the boundary: the sample rows are clustered in-process and never enter context. On a metered connector it takes the same cost handshake as the scanning commands below (only the feature columns are scanned, and a dialect-aware sample clause reads a fraction), so surface the estimate and get a budget first. Needs the[cluster]extra (scikit-learn); the wrapper installs it automatically for this subcommand. -
explore semantic list|values|queryreach the semantic layer: the metrics an author defined, and the semantic models, measures, dimensions and entities they are built out of. Distinct from the warehouse commands above, and from the top-levelsemanticgroup, which authors the layer where this queries it.listis discovery and returns the layer's objects rather than three lists of names: semantic models (the unit the layer is organized around, each with the transformation model it sits on, its default time dimension, and the physicalrelationunderneath), metrics (which dimensions each can be grouped by, the measures it reads, a ratio's two sides, any filter that makes it a subset, the grains it can be queried at, andtime_axis, the physical time column a time grouping resolves to), dimensions (the token to group by, plus the bare definition, owning model, queryable grains andcolumnbehind it), entities (one declaration per semantic model, each with its own join key, so the declared join graph is readable), and measures (the aggregation and expression the number is actually made of, which is often a conditional rather than a column). An element defined as an expression carries no column rather than a guessed one. So "which table is behind this metric" is the metric'ssemantic_modelsfollowed to their relations, andexplore profile <relation>is the next call;--apiexposes no relation at all and declares that inunavailable, so use--localwhen you need the physical side.Three free ways to narrow it, and they compose.
--metric <m>keeps those metrics and what they reach.--for-dimension <d>asks the reverse question, returning the metrics groupable by all the named tokens, which is what you want when you know the slice rather than the metric and is also the cheapest way to find the metrics that can go on one chart against one axis.--search <t>takes a word rather than a name and matches it against every element's name and against the project's own label and description. Each names its scope in the payload (scoped_to,for_dimensions,searched_for), so a subset is never mistaken for the layer; an unknown metric or dimension is refused by name, while a search term that matched nothing comes back as a note. The catalog is also capped, with every cut counted inelidedand named innotesand--fullto lift the caps.elidedis always present, so all zeros and no cap notes is the positive statement that this is the whole layer. Prefer narrowing over--full: it decides which part comes back rather than letting a cap decide.values <dimension>returns that dimension's value domain, which is what you need before writing a--wherefilter and the one thing no other dex command can reach on a hosted layer (profilecannot see a semantic dimension). A PII-flagged dimension refuses this command outright rather than being screened, because the whole output is values.querytakes a positional metric after the explicit mode (with--metrickept for compatibility), a--group-by <entity__dim>, and optional--where,--order-by,--grainand--limit, and returns the metric's values as a capped columnar result. Name flags take a comma-separated list or a repeated flag (--group-by a,bis--group-by a --group-by b);--whereis never split, because a filter clause carries its own commas.--grainis checked against the grains the layer reports for the metrics queried, so a refusal names the ones that metric has.Two payload fields carry legitimate differences between the backends rather than leaving them to be inferred:
dimension_scopesays whether a dimension row is one declaration or one groupable path, which is why two backends can report different dimension counts for one layer, andunavailablenames fields a backend structurally cannot supply.--localresolves the join graph through MetricFlow where the[semantic]extra is installed, which is what makes its dimension lists the tokens a query can actually use; without it the payload saysdeclarationsand a note names the extra.Three backends answer these commands, chosen by
.dex/config.ymlsemantic.vendorandsemantic.deployment(the oldersemantic.backendspelling still works), overridable with--local/--api. Those two flags name who executes, not which vendor, and every result reports it asexecution(dexorvendor).--localrenders the SQL with MetricFlow and executes it through dex's own connector and cost handshake, so cost is surfaced before spend (needs a dbt project parsed at least once, and the[semantic]extra forvaluesandquery;listreads the project and needs no extra).--apisends the query to a hosted dbt Cloud deployment (needs a host, an environment id and aDBT_SL_TOKEN, plus[semantic-api], and no local project). The hosted backend is the one place the cost guard cannot apply: dbt Cloud executes server-side, so the result carries an explicit warning that spend is governed there and no--confirmis asked. Either way a PII-shaped grouped or filtered dimension (for exampleuser__email) is refused before the query runs, and on--apithe layer's own PII metadata is fetched per metric so a multi-metric query stays authoritative rather than falling back to names.The third backend is
semantic.vendor: ossie, native Apache Ossie documents read out of the repository with no dbt project and no MetricFlow in the path (needs the[ossie]extra). It is catalog-first:listanswers, andvalues,queryand--for-dimensionrefuse by name, because Ossie specifies interchange metadata and no portable query runtime. Those refusals are the format's shape rather than a missing feature, and each one names the physical route instead: a dimension carries itssemantic_model, that model carries itsrelation, andexplore profilethenexplore queryreach the values under the firewall and the cost guard.--apiis refused too; Ossie has no hosted deployment.Read
${CLAUDE_SKILL_DIR}/references/semantic-playbook.mdbefore running a metric query: a metric'stime_axis,filterand measures decide what the number is, and the playbook covers the discovery order, the additivity and time-axis traps this surface is full of, whenvaluesanswers rather than a query, and what changes when the layer is native Ossie.
Rules of engagement for query: prefer the fixed commands when they answer the
question; one probe answers one question; batch related measures into a single
query rather than issuing many; aggregates over PII-flagged columns must be
measuring (COUNT, APPROX_COUNT_DISTINCT, AVG(LENGTH(...))), never value-carrying
(MIN, ANY_VALUE, STRING_AGG). The FROM clause may unnest JSON and array
columns in the connector's native idiom, which is the right way to explore
schemaless data (for example "which keys appear across every row of this JSON
column"): BigQuery t, UNNEST(JSON_KEYS(doc)) AS k, Snowflake
t, LATERAL FLATTEN(input => doc) f, Databricks
t LATERAL VIEW EXPLODE(json_object_keys(doc)) x AS k, Postgres
t, jsonb_object_keys(doc) AS k, Redshift t, UNPIVOT t.doc AS v AT k,
DuckDB t, UNNEST(json_keys(doc)) AS u(k), ClickHouse
t ARRAY JOIN JSONExtractKeysAndValuesRaw(doc) AS kv (there is no lateral
join; ARRAY JOIN is the expansion). The unnested value must come from
a column of a table in the query (bare, or through a JSON/array function);
unnesting a subquery, another table, a literal, or a generator is refused,
and the unnest's outputs inherit the source column's PII flags. A column whose
flag was de-rated below the blocking threshold projects normally, with an
envelope warning naming it; treat the warning as information for the user, not
an error to fix. If the user says a refused column is not personal data,
recommend a pii_overrides entry in .dex/config.yml (fully qualified column,
optional reason): it unblocks querying immediately, survives re-profiles, and is
reviewable in git. Never hand-edit .dex/cache.json to clear a flag. Never fall
back to raw Python or a database CLI to run SQL; the firewall path is the only
sanctioned one.
Cloud and database targets (BigQuery, Snowflake, Databricks, Postgres, Redshift, ClickHouse)
A remote warehouse or database replaces --path with connector config. Start
with connect test --connector <name> (or set connector: plus the matching
block in .dex/config.yml: bigquery: with project and a datasets
allowlist, snowflake: with the pinned warehouse and a databases
allowlist, databricks: with the pinned SQL warehouse and a catalogs
allowlist, postgres: with a schemas allowlist, redshift: with the
Serverless workgroup and a schemas allowlist). Credentials are
discovered, never asked for: if the envelope reports missing or expired
credentials, relay the fix it names (for BigQuery
gcloud auth application-default login; for Snowflake a connections.toml
entry or SNOWFLAKE_* env; for Databricks databricks auth login or
DATABRICKS_* env; for Postgres DATABASE_URL, PG* env, or a
pg_service.conf entry; for Redshift the AWS credential chain
(aws configure, AWS_* env) or REDSHIFT_* env) and never ask the user to
paste a key, token, or password.
On a metered connector, scanning commands (profile, map, relationships,
query) run a two-step handshake. The first call returns needs_confirmation
with an estimate in cost.estimate, a per-table breakdown where relevant, and
the unit it is counted in: bytes on BigQuery, warehouse-seconds on Snowflake
(credits alongside) and Databricks (DBUs), compute-seconds on Redshift
(RPU-hours), database-seconds on Postgres and ClickHouse (no dollars; the
guarded quantity is load). Surface the estimate to the user in human units, get
an explicit budget from them, and re-issue the same command with --confirm and
--budget <magnitude> in that unit. Never invent a budget the user did not
agree to, and never retry with a raised budget on an over-ceiling refusal
without asking. Metadata is free (connect test, inventory run immediately),
and OK envelopes report actual spend under data.spend.
An over-ceiling refusal now carries a calibration line drawn from
.dex/spend.jsonl: what this connector's last few settled commands actually
billed as a fraction of what they were estimated at, or a sentence saying the
project has too little history to say. On a partitioned or clustered warehouse a
dry-run estimate is an upper bound, so this is often the difference between a
budget that admits the work and one that does not. Relay it verbatim when you
surface the refusal, and note the part callers get wrong: the ceiling is checked
against the estimate, so a budget set at the observed fraction of the estimate
is refused again. It is still the user's decision, never yours.
When a needs_confirmation envelope carries suggested_session_ceiling, the
project has never decided whether the day's total spend is bounded, and this is
the one time it is asked. Surface it beside the per-command estimate and get the
user's answer: --session-ceiling <value> sets a cumulative cap for the project
(the suggestion is five times this command's estimate, a starting point, not a
recommendation), and --no-session-ceiling records that the project runs
unbounded. Either one is written to .dex/config.yml and reported as a diff, and
nothing asks again. Add it to the same re-issue that carries --confirm --budget, or the confirmed run will stop once to ask. Never answer it on the
user's behalf: it is a durable project setting, not a per-command flag.
On BigQuery a profiling estimate holds a 10 MB floor per table for each
escalation query a profile may still issue after its aggregate scan, so on a
warehouse of many small tables most of the number can be reserve for work that
never happens. Both the handshake and the over-ceiling refusal report that split
(reserved_bytes and reserved_queries, and in the prose). Pass it on when you
surface the estimate: whether a number is scan or reserve changes whether
raising the budget is buying work or headroom.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 25
- Forks
- 8
- Last commit
- Sep 2026
- Hacker News mentions
- 20
ahel review
K1binfo
installs-packagesK6low
bundled executables the agent is told to runK1info
remote-installer-piped-to-shell (in scripts/run.py)K1binfo
installs-packages (in scripts/run.py)
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
explore-exmergo- Source
- github.com/exmergo/dex
Related picks
Skill · unknown-333
The pick for dbtusing-dbt-for-analytics-engineering
Skill · dbt-labs
The pick for dbtwrite-script-duckdb
Skill · windmill-labs
The pick for DuckDBduckdb-en
Skill · aliyun
The pick for DuckDBdbt-transformation-patterns
Skill · wshobson
The pick for dbtdbt-project-analyzer
Skill · a5c-ai
The pick for dbt