Next to your models
Dashboards you can code review.
An open source language: the query, charts, and layout in one YAML file.
uv tool install dbt-chartstitle: Documents by monthsource: dbqueries: documents: | SELECT DATE_TRUNC('month', created_at) AS month, COUNT(*) AS docs FROM documents WHERE created_at >= '2025-10-01' GROUP BY 1charts: documents: query: queries.documents type: bar x: month y: docsThe chart is only four lines.
Two minutes to a board
One install. One sentence to your agent.
dct is a Python CLI. Install it, then write the YAML yourself
or say the sentence to Claude Code, Codex, or Cursor: the tool teaches the
agent the rest, in your repo or a fresh folder.
uv tool install dbt-chartsMake charts of this with dbt charts. Start with: uv tool install dbt-charts && dct skills introAgents that arrive on their own read dbtcharts.com/llms.txt.
Two declarations
SQL declares what to see. Now declare how to see it.
SQL is declarative. You do not tell the warehouse how to fetch the rows, you say which rows you want and it works the rest out. That is the whole reason it has outlived every tool built on top of it.
Charting never got the same treatment. You get a GUI that hides the decisions, or an imperative plotting API where the answer depends on the order you called things in. dbt charts is the missing half: a declarative language for how the data should be seen, wrapped around the one you already write.
What you want
SELECT date_trunc('month', order_date) AS month,
sum(revenue) AS revenue
FROM fct_orders
GROUP BY 1How you want to see it
type: bar
x: month
y: revenue
stack: normalizeNeither half hides logic from the other. The query owns what the numbers mean; the chart owns nothing but how they look.
A board, from scratch
Start with a query. Add only what you need.
The file on the left is the whole board, not an excerpt, and the board on the right is what that file renders. Nothing here is a mockup, and the highlighted line is the only thing each step changed.
title: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6charts: users: query: queries.users type: tabletitle: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6charts: users: query: queries.users type: scatter x: month y: userstitle: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6charts: users: query: queries.users type: bar x: month y: userstitle: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6charts: users: query: queries.users type: line x: month y: userstitle: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6 by_source: | SELECT signup_source, COUNT(*) AS users FROM users GROUP BY 1 ORDER BY 2 DESCcharts: users: title: Total users query: queries.users type: line x: month y: users by_source: title: Where they came from query: queries.by_source type: bar x: signup_source y: usersrows: [users, by_source]title: User growthsource: dbqueries: users: | SELECT DATE_TRUNC('month', created_at) AS month, SUM(COUNT(*)) OVER (ORDER BY MIN(created_at)) AS users FROM users GROUP BY 1 ORDER BY 1 LIMIT 6 by_source: | SELECT signup_source, COUNT(*) AS users FROM users GROUP BY 1 ORDER BY 2 DESCcharts: users: title: Total users query: queries.users type: line x: month y: users by_source: title: Where they came from query: queries.by_source type: bar x: signup_source y: userscols: [users, by_source]A board starts as a named query. Point a chart at it and you get the rows back, in the order the SQL returned them. The query owns what the numbers mean.
Say how to see it. The chart layer never aggregates and never reorders: it reads the columns the query already produced, so nothing you write here can change the number.
Every chart type reads the same two columns, so trying another one is a one word edit rather than a rewrite.
Nothing about the query moved. The type is the only line that differs from the step before, and the axis, the labels and the numbers all follow on their own.
Add a query, add a chart, and list them. Rows stack them down the board, each one the full width.
Columns put them side by side. That is the whole layout language: no drag targets, no pixel coordinates, and a diff that says what moved.
One system, not a pile
Charts that fit together.
Most chart libraries are a collection of separately invented things: every type has its own idea of a margin, a legend, a label. Put four of them on a board and nothing lines up. Ours share one grid, one type scale and one set of spacing rules, so a board reads as a page rather than a scrapbook.
One grid
Charts snap to the same column grid, so edges line up across a row without anyone nudging a margin.
One type scale
A title is the same size on every chart type. Axis labels, legends and annotations all resolve from the same ramp.
One set of spacing rules
Plot padding, gutters and card spacing come from the theme, not from whatever each chart type happened to be built with.
The range
Every one of these is YAML.
Rendered by the same engine, from the same syntax you just watched being built up. No plugin API, no escape hatch into JavaScript.
One line
Fully customizable themes
Same query, same chart, same board file. The default is on the left and the rest cycle past it on the right. The only line that differs between any two of them is the third one, and a theme reaches everything from there: the ground, the palette, the type, the grid, the spacing between things.
Themes cascade like everything else: the project, then the theme, then the board, then the chart. A theme is a file like any other and can extend another, so there is no settings screen whose state you cannot diff.
Prose
Markdown, wherever text goes
Titles, notes, annotations and whole text cards take full markdown: headings, lists, tables, links, emphasis, images. A board is allowed to explain itself, so the finding and the chart that supports it live in one file instead of a chart here and a Slack message there.
- type: text
body: |
## Why Q3 turned
Two things moved: enterprise renewals
landed early, and **self-serve churn**
fell for the first time in a year.
See [the cohort board](./cohorts).Visualization linting
Visual feedback for the AI
A model writes the board but never sees it. It cannot tell that the labels collided, that the bars packed into a smear, or that the last three columns truncated. So the renderer measures the result and says so, in words: the code, the numbers behind it, and the line to change.
Every render is linted against the same design rules a person would apply squinting at the chart. The agent reads the warning and fixes the one line that caused it, before anyone opens the board.
$ dct render charts/revenue.yml
WARN-BAR-BAND-WIDTH-TOO-NARROW
charts/revenue.yml:14
182 bands x 2 series across 640px
(~1.4px per bar), so bars will read as
a merged block instead of separate marks.
Fix: roll up to a coarser grain (day -> week).
WARN-TABLE-COLUMNS-OVERFLOW
charts/revenue.yml:31
Table needs 980px but only 640px is available.
Fix: drop columns or widen the slot.In your dbt project
charts/ right next to your models/
No new repo, no second home for the definitions. Run dct anywhere inside a dbt project and it finds dbt_project.yml on its own, then connects through the profiles.yml you already keep. No second set of warehouse credentials to store or rotate.
Point a query at a model with ref() and the dependency is real. Boards ship on the same branch as the column rename that would have broken them, in the same pull request, reviewed by the same people.
my_dbt_project/
├── dbt_project.yml
├── profiles.yml
├── models/
│ ├── staging/
│ └── marts/
│ └── fct_users.sql
└── charts/
├── users.yml
├── pipeline.yml
└── themes/
└── newsroom.ymlReviewed like code
Broken charts fail in CI
Because a board is a file that names the models it reads, the thing that usually breaks a dashboard, someone renaming a column, is something CI can catch on the pull request that renames it. Not in a Slack message on Monday.
Structural, with no credentials
dct init ci writes a GitHub Actions workflow that runs dct validate on every push. It parses the boards and checks them against the schema without a warehouse connection, so it runs on a fork's pull request.
Against your dbt models
Add dbt parse and every board query reaching a model through ref() is checked against the columns that model actually produces. A rename that would break a board fails the check that renames it.
Against the warehouse itself
dct validate --warehouse puts each query to the warehouse by the cheapest means it offers: DESCRIBE on DuckDB, a dry run on BigQuery, EXPLAIN on Postgres, Redshift and Snowflake. No rows scanned, no bill.
On the pull request that renames the column
$ dbt parse && dct validate charts
ERR-DBT-MODEL-COLUMN-MISSING
charts/documents.yml:14
Column 'signup_source' is not in ref('fct_users').
Did you mean 'acquisition_source'?
1 board failed, 14 passed.Or before you rename it at all
$ dct impact signup_source --table fct_users
2 boards read this column:
charts/documents.yml
charts/growth/acquisition.ymlOn dbt charts Cloud the same review happens in the browser: an edit pushes to a branch, Cloud keeps a pull request open against it, and a reviewer sees the board rendered beside the diff before approving the merge.
And when you want it hosted
The language is free. The workspace is the product.
Everything on this page runs on your machine with no account. dbt charts Cloud adds the things a language cannot give you on its own: a live warehouse connection, permissions, scheduled renders, a place to share a board, and conversational analytics over the boards you have already built.
Write your first board in about ten minutes.
One install at the terminal, or one sentence to Claude, Codex, or Cursor. The tool teaches the agent the rest.
uv tool install dbt-chartsMake charts of this with dbt charts. Start with: uv tool install dbt-charts && dct skills intro