---
title: Author a dashboard with your AI
description: The real publishing loop, from introspecting your schema to publishing by spec hash, and what to check at each step so you catch problems early.
updated: 2026-08-04
tier: free
---

You do not write the dashboard. You describe the numbers you want, and your AI
writes a **spec**: one small YAML document of datasets and tiles. The Dashies
server compiles it into HTML, validates it, runs the SQL once to bake real
numbers in, and gives you back a URL.

This page is what that loop looks like from your side, and what to check at each
step. Your AI runs the tool calls; you read the reports.

## The loop

::::steps

### 1. Your AI reads the schema

It calls `introspect_schema` on the data source you are building against, which
returns table and column names. No data rows are read.

**Check:** your AI should tell you which connection it is reading from, by name
and engine, before this call. What a connection is bound to, and why that cannot
change, is in
[Connections and scope](/concepts/connections-and-scope#scope-is-an-immutable-pair). If it does not say, ask. The SQL runs server-side
inside Dashies against your warehouse, using the credentials you stored in the
web app, and that is worth knowing before it starts.

### 2. Your AI writes and validates the SQL

It writes one read-only `SELECT` per dataset and checks each with
`validate_cube_sql`. The design rules it is working to are in
[Designing the cube](/concepts/designing-the-cube#grain). That proves the statement runs inside the same confined
executor the scheduled refresh will use, under the same caps and timeout.

**Check:** `validate_cube_sql` proves the SQL **runs**. It does not prove the
numbers are **right**. Do not treat a clean validation as a correctness result.
See [Verify your numbers](/guides/verify-your-numbers).

### 3. Your AI dry-runs the spec

It calls `publish_dashboard` with `dry_run: true`. The server does the full
compile, validation, and a read-only seed of every dataset, and writes nothing.

The report comes back with:

- `spec_hash`, which names the exact document the server just checked,
- `mode_choices`, saying what each dataset resolved to and why,
- `warnings`, which are advisory,
- `obligations`, which are not,
- `bytes`, and `errors` if there are any.

**Check:** read `mode_choices` and `obligations` yourself. Both are covered
below.

### 4. Your AI publishes the hash

With a clean dry run, it calls `publish_dashboard` again passing `spec_hash`
rather than the document. The server compiles, validates, and seeds exactly the
same document. Nothing is skipped; the only thing saved is re-sending the YAML.

The response carries the URL.

**Check:** open the URL. The page should load with real numbers already in it,
not a loading state.

::::

:::note{title="Why publishing by hash matters"}
A dry run stores the document server-side under its hash for about an hour, so
the real publish names the hash instead of carrying a second copy. A large spec
re-sent on every correction is the single most common waste on this path.

If the hour lapses, the publish is refused naming that, and your AI re-sends the
document. That is a miss, never a wrong publish: the server re-checks the digest
before using the stored entry.
:::

## What a spec looks like

You do not have to write this, but reading one makes the reports below make
sense. This is a complete, valid spec:

```yaml
dashies: 1
title: Dashboards published
slug: dashboards-published
source:
  connection: self
  schedule: daily
datasets:
  main:
    mode: cube
    sql: >
      select day, dashboards_published
      from dashies_usage_metrics
      order by day
    dimensions:
      day: { type: date }
    measures:
      dashboards_published: { agg: sum }
tiles:
  - type: kpi
    measure: dashboards_published
    title: Total published
  - type: chart
    chart: line
    x: day
    measure: dashboards_published
    title: Published per day
```

`source.connection` is required and has no default. `self` is Dashies' own
built-in metrics view, which holds no personal data; anything else is a data
source id. `source.schedule` is the coarse cadence, and
[you refine the timing afterwards](/guides/refresh-schedule).

That example runs as written, because `self` exposes exactly one relation,
`dashies_usage_metrics`, at day grain: `day` plus eight additive counts
(`dashboards_published`, `public_count`, `private_count`, `active_count`,
`paused_count`, `failed_count`, `archived_count`, `scheduled_count`). It is the
only thing `self` can read, so a query against any other table fails there.

Everything else, the `data-dash` markup, the data island, and the refresh
manifest, is emitted by the compiler. Your AI never hand-writes any of it.

## Reading the publish report

### `mode_choices`

Each dataset resolves to one of four materializations, and the report says which
and why, for example `main -> lattice: a distinct count over low-cardinality
dimensions`.

If a mode surprises you, that usually means a measure is not what you thought it
was. See [Choose a dataset mode](/guides/choose-a-dataset-mode) for the decision,
and [the four modes](/concepts/dataset-modes#the-four-modes) for what each one is.

### `obligations`

An obligation is the format asking **you** to run a check no static analysis can
run. It appears whenever a cube is built over more than one row source: a join, a
CTE, a comma join, or a derived table.

Do the check. It is described in
[Verify your numbers](/guides/verify-your-numbers).

:::danger{title="An empty obligations list is not a clean bill of health"}
Empty `obligations` means only that the cube reads one row source, so it cannot
fan out. It says nothing about whether your numbers are correct. A non-additive
aggregate or a mis-declared measure on a single-source cube is still yours to
catch.
:::

### `warnings`

Warnings do not block the publish. They are the server reporting what it saw in
the **real seeded values**, which is the cheapest signal you will get. Several
describe a tile that publishes cleanly and then refuses to draw.

One is worth reading closely every time: a **rolled-up value that disagrees with
its siblings**. If two datasets compute the same measure the same way over the
same column and their fully rolled-up totals differ, and a tile actually displays
the differing one, the report says so and gives you the aggregate, column, scope,
and value for each.

That is not automatically an error. Month-to-date beside year-to-date legitimately
differ, and the scope field is how you tell. But it is exactly the shape of a real
incident: a cohort lattice summing a point-in-time snapshot across 24 tenure
months put $596,348,393 on a KPI card against a true $36,384,217.

## Editing a published dashboard

Editing means editing the spec, never the served HTML. The next refresh rewrites
the data island, so a hand edit to the served page would be lost or left
inconsistent.

The loop is:

1. `get_dashboard_spec` returns the stored spec verbatim, with its comments,
   formatting, and `spec_hash` intact.
2. Your AI republishes to the same path with `spec_edits`, which are exact-string
   replacements, plus `base_spec_hash` set to the hash it just read.

`base_spec_hash` does two jobs: it names the document the edits apply to, and it
is the lost-update guard. If the stored spec changed since it was read, the
publish is rejected with `spec_conflict` and your live dashboard is left
untouched.

Every edit re-seeds and re-validates, so an edit that would break a binding is a
pointered error rather than a broken live dashboard.

:::warning{title="Renaming is not a spec edit"}
To rename a dashboard, your AI uses `update_dashboard` with a new slug, which
keeps the old URL working via a redirect. Changing `slug` in the spec is not a
rename. See [Share a dashboard](/guides/share-a-dashboard).
:::

If your dashboard predates specs, or was published as raw HTML,
`derive_dashboard_spec` reconstructs a draft spec from it first. It is read-only
and stores nothing.

## If a publish is refused

Every refusal names the exact field or the exact gate, for example:

```bash
[semantic] /datasets/main/measures/revenue: measure `revenue` has no matching output column; the query outputs day, orders
```

Fix the thing it names.

:::danger{title="A refused spec is a bug report, not a reason to hand-author"}
The most expensive wrong turn on this path is falling back to a hand-written HTML
file after a refusal, and it is silent. You lose the compiler emitting and
validating the richer tiles, you take over hand-maintaining the data island and
the refresh manifest, and the whole dataset moves into your AI's context, where it
will start deleting real content to make the payload fit.

If your AI starts dropping datasets, dimensions, or rows "to fit", stop it. That
is the signal it left the spec path.
:::

The most common refusal by far is a data source scope mismatch, and on a spec
publish it does **not** stop the publish: the dashboard goes live and only the
refresh manifest is refused, leaving a permanently static page. See
[Work as a team](/guides/work-as-a-team).

## Check it worked

1. **Open the URL.** Real numbers, no loading state.
2. **Ask for the refresh status.** Your AI can call `get_refresh_status` for the
   slug, which reports whether it is refreshing, its schedule, and its next run.
   A dashboard that published fine but shows no schedule and no next run is not
   refreshable, which almost always means the manifest was refused.
3. **Cross-check one number.** Not optional. See
   [Verify your numbers](/guides/verify-your-numbers).
