Skip to content

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

FieldTypeRequiredBounds and notes
dashiesintegeryesAlways 1. The format version.
titlestringyes1 to 120 characters. The dashboard heading.
sourceobjectyesWhere the SQL runs and how often. See Source.
datasetsmapyes1 to 8 entries. Keys match ^[a-z][a-z0-9_]{0,31}$. See Datasets.
assetsmapno1 to 8 entries. Keys match ^[a-z][a-z0-9_]{0,31}$. Images the server fetches and inlines, referenced as asset:<name>. See Assets.
tilesarrayone of1 to 64 tiles. See Tile types. Mutually exclusive with look.
lookobjectone ofYour own HTML instead of generated tiles. Mutually exclusive with tiles. See Look.
slugstringno1 to 64 characters matching ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$. Defaults to the slug in the publish path.
descriptionstringnoUp to 4,000 characters of Markdown, rendered under the title.
intentstringnoUp to 2,000 characters. Notes for whoever edits this spec next. Never rendered.
layoutobjectnocolumns (always 12) and max_width (640 to 1920). Refused alongside look.
themeobjectnoSee Theme. Refused alongside look.
entitlementobjectnoadmins: "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_removedbooleannoSet 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

FieldTypeRequiredBounds and notes
connectionstringyesself, or the UUID of a warehouse connection you may use, or the UUID of the workspace's uploaded-file connection.
schedulestringyesOne of manual, hourly, daily, weekly, monthly.
timezonestringno1 to 64 characters. An IANA zone name, checked against the database's zone table.
uploadstringnoThe 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.

FieldTypeRequiredBounds and notes
sqlstringyes8 to 100,000 characters. One read-only SELECT.
dimensionsmapyes1 to 12 entries. Keys match ^[a-z][a-z0-9_]{0,63}$.
measuresmapyes1 to 24 entries. Same key pattern.
modestringnocube, 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_sqlstringyes for hybrid8 to 100,000 characters. Refused unless mode: hybrid is declared explicitly.
rows_windowintegerno1 to 8,000,000. Refused unless mode is explicitly rows or hybrid.
dataobjectno{ mode: inline } or { mode: parquet }. Refused unless mode: rows is declared explicitly.
entitlementobjectnoWhich column decides who may see a row, and who is granted which values. Requires mode: resolved on the same dataset. See Row-level security.
intentstringnoUp 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.

ConditionResolved mode
Every measure is a sum, count, min, max or a ratiocube
A non-additive measure, every dimension bounded, and no multi or range filter over this datasetlattice
A non-additive measure with an unbounded dimensionrefused: declare mode: rows, or bound the dimensions
A multi or range filter over a non-composable measurerefused: 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

FieldTypeRequiredBounds and notes
typestringnocategory or date. Defaults to a category.
labelstringnoUp to 80 characters. Display name.
domainsarraysee below1 to 200 unique strings, numbers or booleans. Refused on a date dimension.
bucketsintegersee below1 to 1,000. Refused on anything but a date dimension.
intentstringnoUp 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:

FieldTypeRequiredBounds and notes
aggstringyesSee the table of allowed aggregates below.
columnstringnoThe output column to read, when it differs from the measure key. Refused on a cube dataset.
percentilenumbernoStrictly between 0 and 1. Refused unless agg is percentile_cont or percentile_disc.
stockbooleannoDeclares a point-in-time level rather than a flow.
labelstringnoUp to 80 characters.
unitobjectnoSee Units.
intentstringnoUp to 1,000 characters. Never rendered.

A ratio measure:

FieldTypeRequiredBounds and notes
ratioobjectyes{ num, den }, each naming another measure key. Optional num_scope: all and den_scope: all compute that side over the unfiltered dataset.
labelstringnoUp to 80 characters.
unitobjectnoSee Units.
intentstringnoUp 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:

aggcubelattice and hybridrowsresolved
sum, count, min, maxyesyesyesyes
avg, count_distinct, median, percentile_contnoyesyesyes
percentile_disc, stddev, variance, modenoyesnono

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

FieldTypeRequiredBounds and notes
kindstringyescurrency, percent, count or number.
scalestringsee belowcents or units for currency; fraction or points for percent. Required for both, and refused for count and number.
currencystringnoThree upper-case letters, an ISO 4217 code. Refused unless kind is currency.
decimalsintegerno0 to 6.
compactbooleannoRenders 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-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:

FieldTypeRequiredBounds and notes
keystringyesAn output column of this dataset's SQL. Matches ^[a-z][a-z0-9_]{0,63}$.
grantsobjectyesExactly one of sql or list.
grants.sqlstringone of8 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.listarrayone of1 to 500 entries of { identity, values }. identity is an email or a team; values is 1 to 200 key values.
hidden_valuesarrayno1 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.
grainstringnopartition 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:

FormWritten asMatched against
A personan email addressthe viewer's own signed-in email, case-insensitively
A Dashies teamteam:<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.

Look

look replaces the generated tiles with your own markup.

FieldTypeRequiredBounds and notes
htmlstringone of1 to 4,194,304 characters. Your complete dashboard body.
fromstringone of1 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
FieldTypeRequiredBounds and notes
urlstringyesAn 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.
sha256stringnoWritten 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.
intentstringnoUp 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

FieldTypeRequiredBounds and notes
accentstringnoA six-digit hex colour, #RRGGBB.
fontstringnosans, serif or mono.
densitystringnocompact, comfortable or spacious.
modestringnolight, dark or auto.
cssstringnoUp 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.