Reference
Dashboard spec (v1)
Every field of the Dashies spec v1 YAML document: types, bounds, defaults, and the combinations the schema refuses outright.
The spec is a YAML document you hand to
publish_dashboard. The server
compiles it into HTML, validates it, runs each dataset's SQL once to seed the
data island, and stores the manifest that later refreshes read.
The machine-readable schema is published at
https://dashies.ai/schema/dash/v1.json. This page is that schema in prose, and
a build guard fails this site when a field or a tile type exists in the schema
with no entry here.
Everything on this page is the shape contract. The rules about whether the numbers are right live in Measure correctness and Designing the cube.
Top-level fields
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
dashies | integer | yes | Always 1. The format version. |
title | string | yes | 1 to 120 characters. The dashboard heading. |
source | object | yes | Where the SQL runs and how often. See Source. |
datasets | map | yes | 1 to 8 entries. Keys match ^[a-z][a-z0-9_]{0,31}$. See Datasets. |
assets | map | no | 1 to 8 entries. Keys match ^[a-z][a-z0-9_]{0,31}$. Images the server fetches and inlines, referenced as asset:<name>. See Assets. |
tiles | array | one of | 1 to 64 tiles. See Tile types. Mutually exclusive with look. |
look | object | one of | Your own HTML instead of generated tiles. Mutually exclusive with tiles. See Look. |
slug | string | no | 1 to 64 characters matching ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$. Defaults to the slug in the publish path. |
description | string | no | Up to 4,000 characters of Markdown, rendered under the title. |
intent | string | no | Up to 2,000 characters. Notes for whoever edits this spec next. Never rendered. |
layout | object | no | columns (always 12) and max_width (640 to 1920). Refused alongside look. |
theme | object | no | See Theme. Refused alongside look. |
entitlement | object | no | admins: "filtered" | "unfiltered". Absent means filtered, which is the decision rather than a convenience; unfiltered exempts admins of the dashboard's own workspace from the filter, once the dashboard is published carrying it. Declaring it without a dataset-level entitlement is refused. See Row-level security. |
row_level_security_removed | boolean | no | Set it to true to say that a per-viewer row filter this dashboard used to carry is gone on purpose. Publishing a document with no entitlement block over a dashboard that filters is refused unless you declare the removal, so a dashboard cannot stop filtering by accident. Declaring it beside an entitlement block is refused too, which is what keeps one declaration to one removal. |
Exactly one of tiles or look is required. Declaring look also forbids
theme and layout, because in look mode the markup is yours and there is no
generated chrome for either to style.
Source
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
connection | string | yes | self, or the UUID of a warehouse connection you may use, or the UUID of the workspace's uploaded-file connection. |
schedule | string | yes | One of manual, hourly, daily, weekly, monthly. |
timezone | string | no | 1 to 64 characters. An IANA zone name, checked against the database's zone table. |
upload | string | no | The UUID of the uploaded file this dashboard reads. Required when connection is the uploaded-file connection, and refused on any other connection, including self. There is no default and the newest upload is never assumed. |
self is the built-in no-PII Dashies metrics view and needs no setup. A warehouse
connection is created in the Dashies web app, never through MCP, and requires a
paid plan. Resolution rules and the refusals are in
Shared rules.
upload is what makes new data a change to this document rather than something
that happens to the dashboard on its own: without it, republishing a
byte-identical spec could bind a file uploaded since and move the numbers with
nothing in the spec saying so. A new file is a new upload plus a republish
naming it. See Build a dashboard from a file.
timezone anchors the schedule, not the SQL. Nothing propagates it into your
query. Bucket your dates in your business time zone inside the SQL, and never
rely on a session time zone: see SQL dialect notes.
Datasets
A dataset is one SQL statement plus the declaration of what its output columns mean. One to eight per dashboard.
What that statement returns depends on the connection. Against a warehouse
connection or the workspace's uploaded-file source, it returns one row per
underlying record carrying the columns each number is worked out from, and each
number is declared here rather than computed in the SQL: Dashies holds those
rows and works the numbers out when a reader opens the page, so a statement that
has already aggregated gets aggregated a second time, and a count comes back as
the number of groups you made rather than the number of records. Against the
built-in self connection, it groups by your dimension keys and selects one
aggregate per measure, and those rows ship inside the file. A plain total
survives the wrong grain, which is why the mistake lasts: the dashboard reads as
correct until somebody adds a count. See
Datasets and the four modes.
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
sql | string | yes | 8 to 100,000 characters. One read-only SELECT. |
dimensions | map | yes | 1 to 12 entries. Keys match ^[a-z][a-z0-9_]{0,63}$. |
measures | map | yes | 1 to 24 entries. Same key pattern. |
mode | string | no | cube, lattice, hybrid, rows or resolved. Resolved for you when absent, and which value you can get is decided by the connection: resolved keeps the dataset's rows with Dashies to be queried per request, and is the only mode a warehouse or uploaded-file dataset can be given; the other four are the in-file materializations, and they are what the built-in self connection and a free-plan dashboard on the shared Dashies sample data get. See How mode is resolved when you omit it. |
rows_sql | string | yes for hybrid | 8 to 100,000 characters. Refused unless mode: hybrid is declared explicitly. |
rows_window | integer | no | 1 to 8,000,000. Refused unless mode is explicitly rows or hybrid. |
data | object | no | { mode: inline } or { mode: parquet }. Refused unless mode: rows is declared explicitly. |
entitlement | object | no | Which column decides who may see a row, and who is granted which values. Requires mode: resolved on the same dataset. See Row-level security. |
intent | string | no | Up to 2,000 characters. Notes for the next editor. Never rendered. |
Each key of dimensions and measures must equal an output column name of the
SQL. Letter-case differences are reconciled for you; two output columns that
differ only by case are refused, naming both.
How mode is resolved when you omit it
The connection decides which of the two answers below you get.
On a warehouse connection or the uploaded-file source the only mode you can be
given is resolved. Those rows stay with Dashies and are queried per request,
and the four in-file modes are refused on such a connection rather than chosen,
so the table below does not describe it. The measures do not pick a different
mode there, but they can stop you reaching it: four of the twelve aggregates the
schema declares cannot be worked out on a held dataset, so a dataset declaring
one is refused at publish, naming the measures. Measures names
which four. One exception to all of this, and it turns on
the plan rather than on the connection: on a free plan a dashboard built on
the shared Dashies sample data is prepared the in-file way, so the table below is
its answer. See Datasets and the four modes.
On the built-in self connection the resolver only ever picks cube or
lattice. Both ship aggregates. rows and hybrid ship row-level data to
every viewer, so they are always an explicit opt-in and are never chosen for
you.
| Condition | Resolved mode |
|---|---|
Every measure is a sum, count, min, max or a ratio | cube |
| A non-additive measure, every dimension bounded, and no multi or range filter over this dataset | lattice |
| A non-additive measure with an unbounded dimension | refused: declare mode: rows, or bound the dimensions |
| A multi or range filter over a non-composable measure | refused: declare mode: hybrid with rows_sql, or drop that filter |
Because the three row-level fields above are refused when mode is absent, a
dataset that never declares a mode cannot become rows, hybrid or Parquet by
accident.
The trade-offs between the four in-file modes are in Datasets and the four modes.
Dimensions
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
type | string | no | category or date. Defaults to a category. |
label | string | no | Up to 80 characters. Display name. |
domains | array | see below | 1 to 200 unique strings, numbers or booleans. Refused on a date dimension. |
buckets | integer | see below | 1 to 1,000. Refused on anything but a date dimension. |
intent | string | no | Up to 1,000 characters. Never rendered. |
On a lattice or hybrid dataset every dimension must be bounded: a category
dimension needs domains, a date dimension needs buckets. That declaration is
what the 50,000-cell estimate is computed from, so an unbounded dimension there is
refused rather than guessed at.
Measures
A measure is either an aggregate or a ratio. The two shapes are exclusive.
An aggregate measure:
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
agg | string | yes | See the table of allowed aggregates below. |
column | string | no | The output column to read, when it differs from the measure key. Refused on a cube dataset. |
percentile | number | no | Strictly between 0 and 1. Refused unless agg is percentile_cont or percentile_disc. |
stock | boolean | no | Declares a point-in-time level rather than a flow. |
label | string | no | Up to 80 characters. |
unit | object | no | See Units. |
intent | string | no | Up to 1,000 characters. Never rendered. |
A ratio measure:
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
ratio | object | yes | { num, den }, each naming another measure key. Optional num_scope: all and den_scope: all compute that side over the unfiltered dataset. |
label | string | no | Up to 80 characters. |
unit | object | no | See Units. |
intent | string | no | Up to 1,000 characters. Never rendered. |
A ratio is the correct way to express an average, a rate or a share. Store the numerator and the denominator as additive measures and let the runtime divide under whatever filter is on screen; a stored average cannot be re-derived once the filter changes.
The schema declares twelve aggregates, and the mode decides which are legal:
agg | cube | lattice and hybrid | rows | resolved |
|---|---|---|---|---|
sum, count, min, max | yes | yes | yes | yes |
avg, count_distinct, median, percentile_cont | no | yes | yes | yes |
percentile_disc, stddev, variance, mode | no | yes | no | no |
rows and resolved take the same eight, because the schema applies one
rule to both. So percentile_disc, stddev, variance and mode are what a
warehouse or uploaded-file dataset cannot declare at all: there is no other mode
for it to be steered onto, so the publish is refused naming the measures. A
ratio measure is legal in every mode.
stock: true marks a measure that is a level rather than a flow, so it is never
summed across periods. Omitting it on a level is the single most expensive
authoring mistake in this product: see
Measure correctness.
Units
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
kind | string | yes | currency, percent, count or number. |
scale | string | see below | cents or units for currency; fraction or points for percent. Required for both, and refused for count and number. |
currency | string | no | Three upper-case letters, an ISO 4217 code. Refused unless kind is currency. |
decimals | integer | no | 0 to 6. |
compact | boolean | no | Renders 1,200,000 as 1.2M. |
scale says how the raw number is stored. A currency measure holding whole
currency units declares scale: units; a percent measure holding 0.25 declares
scale: fraction.
`cents` and `points` publish only where your own code formats the value
A managed tile has no display divisor, so on a tiles spec the compiler refuses
any measure declaring scale: cents or scale: points, on every dataset mode.
The refusal names the scale you declared and reads:
scale "points" cannot be shown by a managed tile: the tiles have no display divisor, so every figure would render 100x its true value. Divide in SQL (avg(pct)/100.0 as share) and declare scale: fraction. (Markup you write yourself may keep it: on a cube, rows or resolved dataset, a look body reading the measure through dashies.data is handed its value with scale: 100 beside it.)On a tiles spec, divide in the SQL and declare scale: units for currency
or scale: fraction for percent. On a look spec whose dataset is
cube, rows or resolved, the declaration is kept: the measure your
dashies.data callback receives carries scale: 100, the value arrives
undivided, and your code divides before formatting.
Two shapes are still refused on a look spec, and they are different questions -
who draws the figure, and what the data block can carry:
- a scaled measure that a hand-written
data-dashbinding in your markup draws. That is a managed figure again, so it is refused naming the binding.data-groupcounts as drawing every measure of its dataset, because the grouped table rebuilds its column list rather than honouringdata-columns. - a scaled measure on a
latticeorhybriddataset. Those data blocks carry noformatand no divisor, so there would be nothing beside the value to say it is stored at 100x. Usemode: cube,rowsorresolved, or divide in the SQL.
Row-level security
Each viewer is served only the rows they were granted. The filter is applied where the data is queried rather than in the page, so it holds for every tile and for every number a page you wrote yourself asks for: two people open the same URL and see different numbers.
It is an Enterprise capability on a workspace dashboard reading a warehouse.
check_readiness reports whether a space has it, in row_level_security, before a spec is
written; a publish that arrives without it is refused rather than quietly unfiltered.
A dataset-level entitlement declares which column decides who may see a row:
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
key | string | yes | An output column of this dataset's SQL. Matches ^[a-z][a-z0-9_]{0,63}$. |
grants | object | yes | Exactly one of sql or list. |
grants.sql | string | one of | 8 to 100,000 characters. A read-only SELECT run on the same connection as the dataset's own SQL, returning exactly two columns, identity first and key value second, read by position rather than by name. A third column is refused at refresh; two in the wrong order are not, and leave every viewer unmapped. |
grants.list | array | one of | 1 to 500 entries of { identity, values }. identity is an email or a team; values is 1 to 200 key values. |
hidden_values | array | no | 1 to 200 key values that are deliberately hidden from everyone. Write them as strings: the schema admits numbers and booleans and the refresh reader accepts only strings and null, so a number publishes and then stops the next refresh. null is how you say a NULL key is intentional. |
grain | string | no | partition or row. Absent lets the server choose, and the choice is recorded so it is readable rather than inferred. A declared partition is narrowed to row, and recorded as narrowed, wherever one folder per value would disclose those values through the object path. |
An identity is one of exactly two forms, and the set is closed:
| Form | Written as | Matched against |
|---|---|---|
| A person | an email address | the viewer's own signed-in email, case-insensitively |
| A Dashies team | team:<team name> | the teams that viewer belongs to, case-insensitively |
Both are matched as whole strings and there is no wildcard. A viewer's values are the union over every grant that names them, and an empty union is not an error: that viewer gets a designed page saying they have no access to this dashboard's data. An admin the dashboard has exempted is never in that state: they are served every row rather than the no-access page.
A second designed page covers the state where the dashboard's own record says it filters and the page being served carries no filter for any dataset. Unlike the one above, every viewer gets it: it says the dashboard is being set up to show each person their own rows, nothing is served rather than everything, and publishing the dashboard again is what clears it. A refresh does not, because a refresh does not rewrite that part of what is served.
The dashboard-level entitlement carries one field, admins, which is filtered or
unfiltered. Absent means filtered: row-level security applies to everyone including
workspace admins, so forgetting to write it cannot widen what anybody sees. unfiltered is
honoured, and it exempts admins of the dashboard's own workspace and nobody else: a creator who is
not an admin, and every ordinary member, stay filtered. The role is resolved from the workspace
membership rather than from anything the page can say, and what the document declares reaches a
viewer only once the dashboard has been published carrying it, because a refresh does not rewrite
that part of what is served. A workspace admin can also turn the exemption on or off for one
dashboard from the app, which takes effect without a publish and wins over what the document
says. A dashboard-level block with no dataset-level
block is refused - it opts out of a filter that does not exist.
A republish cannot stop the filtering by accident. The block is read off the document being
published, so a full republish that omits it is refused at the path row_level_security_removed
before anything is written. Stopping on purpose is that key, written by hand at the top level as
row_level_security_removed: true; declaring it beside an entitlement block is refused too, which
keeps one declaration to one removal. The same code covers the case where Dashies could not read
whether the dashboard filters, in a different sentence: on that one, publish again rather than
declaring a removal.
Every key value in the data has to be granted to somebody or named in hidden_values. A
refresh that finds one that is neither stops: it publishes nothing, the dashboard keeps the
numbers from its last successful refresh, and Dashies emails the dashboard's author and every
admin of its workspace, naming the first few values plus a count of the rest, and how many rows
they account for; the complete list is on the run detail. There is no override and no margin.
Grant them or hide them, then refresh.
Two rules hold today and are what a publish carrying the block meets:
- An entitled dataset must declare
mode: resolved. The filter is applied where the data is queried rather than in the page, so a dataset that ships its rows to the viewer cannot carry one. - Row-level security is an Enterprise capability, so the workspace the dashboard lives in
has to be on that plan.
check_readinessanswers whether it is available before you write the block; the refusal codes are in Errors.
Look
look replaces the generated tiles with your own markup.
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
html | string | one of | 1 to 4,194,304 characters. Your complete dashboard body. |
from | string | one of | 1 to 64 characters matching the slug pattern. Reuse the markup of another dashboard of yours. |
Exactly one of the two. Every data-dash binding in your markup must resolve
against the datasets you declared, or the publish is refused; the roles and
attributes are in Runtime attributes.
Declaring look forbids tiles, theme and layout.
Assets
assets declares images the server fetches and inlines into the page, so a
logo never travels through an authoring tool as bytes it has to reproduce.
assets:
logo:
url: https://www.example.com/brand/logo.svg
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
url | string | yes | An https URL, up to 2,048 characters. Fetched on every dry run and publish, following at most 5 redirects, every hop checked against the outbound host policy. |
sha256 | string | no | Written by the server: the pin of what was fetched, recorded into the stored spec. Delete the line to accept a file the URL has since changed. |
intent | string | no | Up to 1,000 characters of notes. Never rendered. |
Reference an asset as src="asset:logo" in markup (a look body or a
custom tile) and as url(asset:logo) in CSS (theme.css or a <style>
block). Every reference is replaced by the fetched bytes when the page is
built, so the published page is self-contained. A reference to a name that is
not declared is refused; a declared asset nothing references is a warning.
Each asset must be an SVG, PNG, JPEG, WebP or GIF by its bytes, at most
512 KiB, and all assets together at most 2 MiB. An SVG carrying a script, a
foreignObject, an event attribute or an external reference is refused. The
publish report lists every asset with its content type, byte size and sha256.
On a later publish the pinned bytes are kept if the URL serves different ones,
or if it stops answering, and the report says so. The stored copy stands in only
when it is the recorded file; a copy that is not is refused rather than used.
A look: { from } republish keeps the stored page byte for byte, so an asset
whose bytes would change under it is refused: send the body with
look: { html } to change a logo.
Theme
| Field | Type | Required | Bounds and notes |
|---|---|---|---|
accent | string | no | A six-digit hex colour, #RRGGBB. |
font | string | no | sans, serif or mono. |
density | string | no | compact, comfortable or spacious. |
mode | string | no | light, dark or auto. |
css | string | no | Up to 50,000 characters, injected as a stylesheet. |
css is the escape hatch for anything the four knobs above do not cover. The
custom properties it should target are in
Theme tokens.
Check it worked
Publish with dry_run: true. The pipeline compiles, validates and seeds exactly
as a real publish would, writes nothing, and reports the resolved mode for every
dataset, the compiled byte size against the publish cap, and the rows each dataset
seeded.