Dashboards (.dash)
A .dash file describes a dashboard as data: source blocks name data files, query blocks hold SQL, plot blocks draw the results, and filter blocks let a click on one plot narrow the others. There is no code to write. Both people and AI agents can create the file, review it in a diff, and validate it before anyone opens it.
A .dash file draws standard charts, tables, numbers, and maps from saved queries. Its plain-text format also makes it useful for working with an AI agent: the agent writes or edits the file, ducklocal check verifies that it runs, and you comment on the plots to request changes.
A complete example
This dashboard queries the tutorial's sample file. Download sales.dash into the same directory as sales.csv, then open it.
source "sales" {
path = "sales.csv"
}
query "totals" {
sql = <<SQL
SELECT round(sum(revenue), 2) AS revenue, sum(orders) AS orders,
count(DISTINCT region) AS regions
FROM sales
WHERE $channel
SQL
}
query "by_channel" {
sql = <<SQL
SELECT channel, round(sum(revenue), 2) AS revenue
FROM sales
GROUP BY channel
ORDER BY revenue DESC
SQL
}
query "daily" {
sql = <<SQL
SELECT date, region, round(sum(revenue), 2) AS revenue
FROM sales
WHERE $channel
GROUP BY date, region
ORDER BY date
SQL
}
plot "revenue" {
type = "card"
query = query.totals
value = revenue
title = "Revenue"
width = 4
}
plot "orders" {
type = "card"
query = query.totals
value = orders
title = "Orders"
width = 4
}
plot "regions" {
type = "card"
query = query.totals
value = regions
title = "Regions"
width = 4
}
plot "revenue_by_channel" {
type = "bar"
query = query.by_channel
x = channel
y = revenue
title = "Revenue by channel"
width = 5
}
plot "daily_revenue" {
type = "line"
query = query.daily
x = date
y = revenue
series = region
title = "Daily revenue, per region"
width = 7
}
plot "daily_table" {
type = "table"
query = query.daily
title = "The numbers behind it"
}
filter "channel" {
plot = plot.revenue_by_channel
}It opens as three numbers across the top, the bar chart and the line chart side by side, and the table under them. Each query runs once, however many plots draw from it: totals feeds all three cards, and daily feeds both the line chart and the table. Click a bar in Revenue by channel and the cards, the line chart and the table narrow to that channel; the bar chart itself stays whole, so you can click another.
Open a dashboard
Any of these opens a .dash file as a dashboard tab, next to your queries:
- the + at the end of the tab strip, then Open dashboard;
- dragging the file onto the window;
- naming it on the command line:
ducklocal sales.dash; - from a script or an agent, into the window that is already running:
ducklocal open sales.dash(see the CLI guide).
Plots are laid out on a 12-column grid: each spans its width, plots fill a row left to right, and a plot that no longer fits starts the next row. Drag the dividers between rows to resize them; a dashboard taller than the tab scrolls. A plot that cannot draw — its query failed, or a column is missing — shows the reason in its own panel, and the rest of the dashboard still draws. Open dashboards come back on the next launch, and recently opened ones are listed under Dashboards in the sidebar.
Hover a plot to reveal its controls in the top-right corner:
- SQL turns the plot over to the SQL its query last ran — its sources and the current filter picks filled in, so it runs as it stands — with Copy and Open in a query tab. Chart turns it back.
- The speech bubble opens the plot's comments.
- Enlarge this plot expands it to fill the dashboard tab. Use Back to the dashboard (Esc) or press Esc to return to the grid.
The SQL view highlights syntax. Right-click a dashboard table cell to copy the cell, its tab-separated row, or its column in the displayed order.
Click a table plot's column header to sort it: descending, ascending, then back to the query's order. Numbers sort as numbers and NULL sorts last; a row picked for a filter stays picked through a sort.
The toolbar's View spec switches the tab to an editor for the file, with syntax highlighting (the SQL in heredocs is coloured like the SQL editor's), completion and inline diagnostics. Save or ⌘S writes it back and redraws. If the saved file fails validation, the tab keeps the last working dashboard and shows the error above it. The same happens when another program changes the file. Back to dashboard returns to the plots. Reload re-reads the file and runs every query again.
Review with comments
Comments let you review a dashboard the way you would a document, and hand the changes to an AI agent. The product thinking behind this workflow is explored in What Claude Dashboards taught me about DuckLocal.
In the window, hover a plot and click the speech bubble. A card opens over the dashboard, quoting the plot's title, with the threads already on it and a box to write in. Write what should change — "rank these, biggest channel first" — and press Comment (or ⌘/Ctrl+Enter). Each thread has Reply and Resolve; a resolved thread stays, dimmed, until someone replies to it. A plot with open threads keeps a bubble with their count in view, so you can see at a glance what is waiting.
The agent reads and answers the threads with ducklocal comments, no window needed:
ducklocal comments sales.dash{
"file": "sales.dash",
"comments_file": "sales.dash.comments.json",
"open": 1,
"threads": [
{
"id": "c1",
"plot": "revenue_by_channel",
"plot_title": "Revenue by channel",
"plot_query": "by_channel",
"plot_lines": [68, 75],
"plot_missing": false,
"resolved": false,
"comments": [
{ "author": "user", "text": "Rank these, biggest channel first", "at": "2026-10-10T14:03:00+08:00" }
]
}
]
}Each thread names its plot, the plot's query, and the lines of its plot block in the file, so the agent knows what to edit. It changes the spec, runs ducklocal check, and answers:
ducklocal comments sales.dash --reply c1 --text "Sorted by revenue, descending" --resolveThe window watches both files. Within a second the plot redraws from the edited spec, and the agent's reply appears in its thread, marked Agent.
The threads are kept beside the dashboard, in NAME.dash.comments.json, so they travel with it like its source files do. They are working notes, not part of the dashboard: leave them out of version control, or delete the file once the review is done.
The official agent skill teaches this loop, so an agent that has it only needs to be told "address the comments on sales.dash".
New in 0.4.0
An agent can propose a change rather than editing the file. The tab then offers Review, with the diff and checks, and only you can apply it.
See an agent at work
While an agent runs check, comments or presence for this open dashboard, its name appears in the corner, with its maker's mark — Claude, OpenAI, Gemini, Cursor, DeepSeek — or its initial. Click the name to see the session log. When it edits the file, changed and added plots stay marked (Claude changed this, Claude added this) until you have had them in view for a moment; a removed plot leaves a notice you can dismiss.
An agent can announce its next edit with ducklocal presence sales.dash --doing editing --plot revenue_by_channel. That plot gets a ring; a plot name that does not exist yet gets a placeholder below the dashboard. The session ends after 30 seconds without activity, or an explicit --doing idle. A finished agent stays in the corner while a thread it answered waits for your reply, with the count on its avatar. Comments record which agent wrote them and show its mark. The CLI reference covers the options. These indicators show local activity; they do not send work to an agent.
Blocks
A file is a sequence of blocks, each a type, a quoted name and a body:
source "name" { ... }
query "name" { ... }
plot "name" { ... }
filter "name" { ... }Names must be unique within their kind: two queries cannot share a name, and neither can two plots — but a query and a plot may.
source
| Attribute | Required | Value |
|---|---|---|
path | yes | A .csv, .tsv, .txt, .parquet, .json, .jsonl or .ndjson file, or a glob of them (exports/orders-*.csv) read as one relation |
A source gives data files a name the queries read: with source "sales", a query says FROM sales. The path is relative to the .dash file, not to wherever DuckLocal was started, so a dashboard and its data can move together; ~ and absolute paths work too.
A source never touches the connection: it becomes a WITH sales AS (…) in front of each query that names it, so nothing appears in the sidebar, nothing outlives the tab, and a source shadows a table of the same name for this dashboard's queries only. The name must be a plain identifier (letters, digits and _). A workbook cannot be a source; open it in DuckLocal and query the table it becomes.
query
| Attribute | Required | Value |
|---|---|---|
sql | yes | One read-only SQL statement, as a heredoc or a string |
A query block holds sql and nothing else. It may read $name for a filter.
plot
| Attribute | For | Value |
|---|---|---|
type | all, required | "line", "bar", "stacked_bar", "area", "scatter", "pie", "heatmap", "histogram", "map", "table", "card" or "text" — a quoted string |
query | all except text, required | A reference to a query block: query.by_channel |
x | charts, required | The column on the horizontal axis; a pie's slices; the number a histogram bins. A table shows every column and needs none |
y | charts, required | The column to plot; must be numeric. A pie's slice sizes. A histogram takes none |
series | line, bar, stacked_bar, area, scatter, heatmap | A column whose values split the rows into one series each; a heatmap's rows |
title | all | A heading for the plot, as a quoted string; a card's label |
width | all | How many of the 12 grid columns the plot spans, 1–12. Default 12, or 3 for a card |
value | card | The column to show, from the first row. Default: the first column |
note | card | Text under the value: a quoted string, or a bare column name to read from the first row |
tone | card | "accent", "good", "warning" or "critical"; colours the top rule and value |
format | charts and card | What the numbers are: see Number formats and change. A map or table takes none |
delta | card | A numeric column holding the change, shown under the value |
delta_format | card | A format for the change. Default: the card's |
down_is_good | card | true turns a fall green and a rise red, for costs, latency or churn |
description | all, 0.4.0+ | What the plot shows and how to read it; appears on hovering an ⓘ beside the title |
text | text | Body text as a string or heredoc; supply title, text, or both, with no query |
lat, lng | map | Latitude and longitude columns, in degrees. Either may be left out when the numeric column's name indicates the coordinate — lat, latitude, geo_lat; lng, lon, longitude |
color | map | A column to colour the points by; left out, one is picked when a text column has only a few values |
size | map | A numeric column to size the points by |
size_scale | map | "sqrt" (the default) or "log", for how size scales |
tooltip | map | A list of the columns a point's tooltip shows: [name, passengers] |
Column attributes name columns of the query's result, matched without regard to case. Write a plain name bare (x = date); quote one that is not a plain identifier (y = "revenue (EUR)"). An attribute that the plot's type has no use for — lat on a line, x on a map, series on a pie — is a mistake, not something quietly ignored.
filter
| Attribute | Required | Value |
|---|---|---|
plot | yes | The plot a click picks a value on: plot.revenue_by_channel. A bar, a stacked_bar or a table |
column | for a table | The column whose value is picked. For a bar or stacked_bar, its x when left out |
See Filters for what a filter does.
Plot types
| Type | Draws |
|---|---|
line | A line per series, unfilled |
area | A filled area per series |
bar | A bar per row, one colour per series. With series, the series' bars side by side in each group. A single series labels each bar with its value |
stacked_bar | One bar per x, its series stacked: positive parts up from zero, negative ones down, with the total on top and in the tooltip |
heatmap | x across and series down, each cell coloured by y on one blue, darker for more (lighter in dark mode). A pair the query doesn't return stays empty |
histogram | The rows counted in each range of a numeric x, in round-width bins. Let its query return the rows themselves, not a grouping |
scatter | A dot per point, unconnected, one colour per series |
pie | One slice per x value, sized by y, largest first; past 8 slices the smallest fold into "other". Drawn as a ring with the total in the middle; click a key entry to pick a slice out, and again to restore it |
text | A title, explanatory text, or both; no query runs |
map | A point per row, at its lat and lng; see Maps |
table | The query's result as a grid, columns sized to their content; click a header to sort |
card | One number, large, with the title as its label. Grouped by thousands, a long fraction rounded (11439704.020000005 → 11,439,704.02), and shrunk to fit the card rather than cut off |
Hover a chart for its values, formatted the way the axis shows them (4.4M, not 4400000).
A bar chart labels every category. When the labels do not fit under their bars, the chart lies on its side, with the labels in a column on the left. Only a time axis thins its labels, keeping as many as fit — always the first and the last — and a bar over a DATE column fills in the days the query returned nothing for as empty bands, so a quiet stretch reads as a gap rather than vanishing. Line, area and scatter axes thin their labels the same way.
The x axis is categorical for every chart type: values are spaced evenly in the order the query returns them, not by their distance from one another. A scatter of two numeric columns is therefore not a true x–y plot.
Rows are plotted in the order the query returns them, so end a query that feeds a chart with ORDER BY.
New in 0.4.0
A dashboard line or scatter over times is placed by time, so uneven gaps look uneven, and it draws in time order whatever the ORDER BY. A row with a NULL time is left off, with a note. A NULL reading the query returned breaks the line. An area stays evenly spaced and says so. A series keeps its colour by name, and every chart says when its query was cut short.
A card shows its query's first row. When the query returns more than one row, the card says so under the number; when it returns none, it shows —.
Cards and text panels
Cards can explain a number with note and give it a status colour with tone. Cards in the same row share a value font size. Text panels add headings or context without running SQL:
plot "revenue_summary" {
type = "card"
query = query.totals
value = revenue
title = "Revenue"
note = "Current channel selection"
tone = "accent"
width = 4
}
plot "reading_guide" {
type = "text"
title = "How to read this dashboard"
text = "Click a channel to filter the daily chart and revenue cards."
width = 8
}A text panel accepts only type, title, text and width. Use text = <<TEXT with a closing TEXT line for a longer explanation. Charts, pies and maps use eight distinct hues, with shades adapted to the current theme and a key for multiple series.
Number formats and change
format shapes a card's value, the value axis, bar labels and tooltips:
| Value | Reads |
|---|---|
"num0", "num1", "num2" | A number with that many decimal places |
"pct" (the same as "pct1"), "pct0", "pct2" | A share: 0.421 reads 42.1%, so return the fraction, not ×100 |
"usd", "eur", "gbp", "cny" | Money, with two decimal places |
"usd0", "eur0", "gbp0", "cny0", "jpy" | Money, in whole units |
A card shows a change with delta, under the value as ▲ 12.3% or ▼ $125. It is green when good and red when not, and the note follows it to say against what. Compute the change in the card's query:
plot "revenue_total" {
type = "card"
query = query.totals
value = revenue
format = "usd0"
delta = growth
delta_format = "pct1"
note = "vs last week"
}Layout
width is how many of the dashboard's twelve columns a plot spans. Plots fill a row from the left in the order they are written and wrap when the next one does not fit, so four cards (3 each) make a row of numbers, and two width = 6 charts sit side by side beneath them. A row short of twelve leaves the rest empty. A row of nothing but cards starts at a short height, a row of nothing but tables starts as tall as its longest table's rows, up to a chart's height. A row of text panels fits its text; other rows start at chart height. Row resize handles appear on hover.
Maps
plot "stations" {
type = "map"
query = query.stations
lat = geo_lat
lng = geo_lng
color = line
size = passengers
size_scale = "log"
tooltip = [name, passengers]
}A map plots each row as a point, on a Web Mercator projection fitted to the points, over a latitude/longitude grid labelled in degrees.
color: up to five values each get a colour; with more, the four most common do and the rest share an "Other" colour. A key reads them back. Left out, the points are coloured by a text column with only a few distinct values, when the result has one.size: a point's area follows the value, so twice the value is twice the ink.size_scale = "log"suits values spanning orders of magnitude. A key in the corner reads sizes back.tooltip: without it, a point's tooltip lists the size column and the other numbers, then the coordinates.
Under the points, a map can draw an OpenStreetMap base map. It is off until you turn it on — View → Online base map (OpenStreetMap) — because loading tiles needs the network. What it sends is which map tiles it wants, which reveals roughly the area being looked at, never a row of your data. Tiles are cached on disk for a week (see Settings and app data); offline or off, the map draws over its latitude/longitude grid alone. The same switch covers the map in the query results' Chart tab.
Filters
A filter makes a plot clickable, and lets the value picked there narrow other queries:
filter "channel" {
plot = plot.revenue_by_channel # a click on a bar picks its channel
}
filter "day" {
plot = plot.days # a table: a selected row picks …
column = day # … the value of this column
}
query "daily" {
sql = "SELECT date, sum(orders) AS orders FROM sales WHERE $channel AND $day GROUP BY 1"
}A query reads a filter as $name, a condition: write it where a condition goes. While nothing is picked it is TRUE, so the dashboard opens exactly as it would without the filter. Once a value is picked it becomes ("channel" = 'Web') — the column named as the filter's column, compared with the value picked; a cell displayed as NULL matches SQL NULL or the literal text NULL. Several filters combine however the query combines them.
- On a bar chart, click a bar to pick it: it keeps its colour while the others fade. Click it again to clear the pick.
- On a table, select a row, by clicking or with the arrow keys.
- The picks in force show as chips above the plots; each chip's ✕ clears its filter, and Clear all clears every pick.
- The picking plot is never narrowed by its own filter. Its query reads
$channelasTRUEeven while a channel is picked — otherwise one click would reduce the bar chart to the bar just clicked, with nothing left to click next. This is cross-filtering, as in Mosaic or crossfilter. - Only what changed re-runs. A pick re-runs the queries whose SQL it changes; a query that reads no filter keeps its result.
A filter holds one value at a time. Pickable plots include a click-to-filter hint below the title. Series retain their colours as filtering changes which groups remain. A query using $channel must expose the filter's column where that predicate is used; ducklocal check now binds a picked-value variant as well, so a missing column is caught before the first click.
New in 0.4.0
Picking a cell displayed as NULL selects only SQL NULL, and the text 'NULL' selects only itself. Pickable plots no longer show a click-to-filter hint below the title.
The picked value goes into the SQL as a quoted literal, and DuckDB converts it to the column's type when comparing, so dates and numbers work as well as text. Picks last while the tab is open and survive a reload, as long as the filter is still in the file; they are not remembered between launches.
Values
| Form | Example | Used for |
|---|---|---|
| String | "Revenue by channel" | type, title, path, size_scale, tone, literal note, text, quoted column names, short sql |
| Heredoc | <<SQL … SQL | sql, text |
| Reference | query.daily, plot.revenue_by_channel | a plot's query, a filter's plot |
| Bare name | channel | column attributes |
| Number | 6 | width |
| List | [name, "Unique IPs"] | tooltip |
A string ends at its line and understands \n, \t, \" and \\.
# and // start a comment that runs to the end of the line. Attributes need no separator; line breaks are just whitespace.
That is the whole language: no variables, no functions, no conditionals, and no interpolation beyond a filter's $name.
Heredocs
A heredoc holds text across lines — in practice, SQL:
sql = <<SQL
SELECT 1
SQL- The delimiter after
<<is any word (SQLby convention), and nothing may follow it on its line. - The heredoc ends at the first line that is exactly the delimiter, give or take surrounding spaces.
- The closing line's indentation is removed from every line of the text, so the SQL can sit at the block's indentation without carrying it.
SQL rules
A dashboard runs its queries whenever it is opened, reloaded or saved, and when DuckLocal starts with the tab restored — so a query may only read:
- Allowed:
SELECT,WITH …,FROM-first queries,VALUES,SHOW,DESCRIBE,SUMMARIZE,PIVOTandUNPIVOT. - Refused: anything that could change data or files —
CREATE,INSERT,UPDATE,DELETE,DROP,COPY,ATTACH,INSTALL,LOAD, and so on. Opening a.dashfile someone sent you cannot change your data. - One statement per query.
- A
PIVOTinside another query names its values. A query that starts withPIVOTmay leave the values for DuckDB to find (PIVOT orders ON channel USING sum(amount)). One nested inside aSELECTor aWITHneeds an explicit list:ON channel IN ('Web', 'Store'). $namemust name a filter block. The read-only check judges the SQL with every$nameasTRUE. A$inside a string, a quoted identifier, a comment or a$$ … $$string is left alone.
Queries run on the window's own connection, so they see everything the SQL editor does: opened files, attached databases, and connection-level state such as TEMP tables.
Paths in SQL
Since v0.2.2, relative file paths in dashboard SQL are searched from the .dash file's folder too. For example, FROM 'sales.csv' can read the CSV beside the dashboard when it is opened from another directory. This applies in the window and in ducklocal check.
DuckDB still checks the working directory first for a single file named directly in SQL: a same-named file there can take precedence. A source block resolves its path explicitly against the dashboard folder and avoids that ambiguity. Absolute paths and registered views remain available.
Limits
| Limit | What happens |
|---|---|
| 12 series per plot | Further series are left out, and a notice says how many there were |
| 1,500 points per series | Points are bucket-averaged down to fit |
| 200 bars | Further bars are left out |
| 8 pie slices | The smallest fold into one "other" slice |
| 200 heatmap columns, 12 rows | Further columns and rows are left out |
| 5–40 histogram bins | About √rows of them, each 1, 2, 2.5 or 5 × a power of ten wide |
| 20,000 map points | Further points are left out, and a notice says so |
Validate a dashboard
ducklocal check validates a file without opening a window — the step to run before handing a dashboard to anyone:
ducklocal check sales.dash
ducklocal check sales.dash --database warehouse.duckdb
ducklocal check sales.dash --summaryFirst the static checks: the syntax, the names and references, the required attributes, every $name against the filter blocks, and each query's SQL through DuckDB's own parser. Then every query runs, read-only, with its sources and with nothing picked, and each plot's columns are checked against the columns its query really returns — including that y, a map's coordinates and its size are numeric, and that each filter's column is one its plot's query returns. Running matters: a cast that fails or a glob that matches nothing only shows once rows are read.
- Without
--databasethe queries run in memory, which is all a dashboard over its ownsourcefiles needs. A query that reads a table no source defines is listed underunresolvedrather than failed, and its plots' columns go unchecked — it is probably meant for a database. - With
--database PATHthey run against that database, opened read-only. Pass it whenever the queries read a database's tables.
Each query that uses a filter is also bound with a picked value, without executing that variant. This catches predicates that refer to a column unavailable in that query. Blocks with mistakes do not prevent sound blocks from being checked: diagnostics include both spec errors and query failures from the same run.
Success prints a JSON summary of the sources, queries (with their result columns and how many rows each returned), filters and plots, plus empty: the queries that ran and returned no rows. That is not an error, but usually a regex, filter or join that matched nothing, so look at each before handing the dashboard over. --summary prints one JSON line of counts and the names of the unresolved and empty queries. A mistake exits with status 2 for spec diagnostics and lists every problem at once, one file:line: message each. SQL-only, database or file failures exit with 1. The CLI guide has the details.
New in 0.4.0
ducklocal check FILE --restricted --allow-read PATH checks a dashboard you didn't write under limits. See Limit SQL you did not write.
Common messages
| Message | Fix |
|---|---|
type is a string in quotes | Write type = "line", not type = line |
Unknown plot type: "donut"; one of line, bar, area, scatter, pie, map, table, card, text | Use one of the listed types |
plot block "p" requires y | Every chart type needs a y; table, map, card and text do not |
plot "p" draws query "q", which the file does not define | Add the query block, or fix the name in query = query.… |
query names a query block: query = query.some_name | The query attribute takes a reference, not a string |
plot "p" draws y = "channel" (VARCHAR), which is not numeric | Point y at a number column, or use type = "table" |
A map plot places points by lat and lng; it takes no x | A map has no axes: give lat and lng, or leave them to the column names |
width is how many of the dashboard's 12 columns the plot spans | Write a whole number from 1 to 12 |
query "q" reads $chanel, but no filter block is named "chanel" | Fix the name, or add the filter block |
filter "f" picks on plot "p", a line; a value is picked on a bar or table plot | Point the filter at a bar chart or a table |
a dashboard query must be a single read-only statement | Move writes to the SQL editor, or name a nested PIVOT's values with IN; see SQL rules |
Unknown block: "card"; a card is a plot: plot "x" { type = "card" … } | Every panel is a plot block, cards and text panels too |
"minutes" is a SQL keyword, so as a column alias it needs AS | Write count(*) AS minutes, or quote the alias |
Unterminated heredoc: no line is exactly SQL | Add the closing delimiter on a line of its own |
Edit in your own editor
ducklocal lsp is a language server for .dash files: diagnostics as you type, completion for block types, attributes, plot types and the names after query. and plot., hover documentation, and go-to-definition from a query.name or plot.name reference to its block. Setup for Neovim and VS Code is in the CLI guide.
