---
title: Dashboard spec (v1)
description: 'Every field of the Dashies spec v1 YAML document: types, bounds, defaults, and the combinations the schema refuses outright.'
updated: 2026-09-20
---

The spec is a YAML document you hand to
[`publish_dashboard`](/reference/mcp-tools/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](/concepts/measure-correctness)
and [Designing the cube](/concepts/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](#source). |
| `datasets` | map | yes | 1 to 8 entries. Keys match `^[a-z][a-z0-9_]{0,31}$`. See [Datasets](#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](#assets). |
| `tiles` | array | one of | 1 to 64 tiles. See [Tile types](/reference/tiles). Mutually exclusive with `look`. |
| `look` | object | one of | Your own HTML instead of generated tiles. Mutually exclusive with `tiles`. See [Look](#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](#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). |
| `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](/reference/mcp-tools/conventions#connections).

`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](/guides/upload-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](/reference/sql-dialects).

## 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](/concepts/dataset-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](#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](#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](#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](/concepts/dataset-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](/concepts/dataset-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](#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](#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](/concepts/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`.

:::warning{title="`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:

```text
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`](#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-dash` binding in your markup draws.
  That is a managed figure again, so it is refused naming the binding. `data-group`
  counts as drawing every measure of its dataset, because the grouped table
  rebuilds its column list rather than honouring `data-columns`.
- a scaled measure on a `lattice` or `hybrid` dataset. Those data blocks carry no
  `format` and no divisor, so there would be nothing beside the value to say it is
  stored at 100x. Use `mode: cube`, `rows` or `resolved`, 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_readiness` answers whether it is available before you write
  the block; the refusal codes are in [Errors](/reference/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](/reference/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.

```yaml
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](/reference/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.
