---
title: publish_dashboard
description: Compile a Dashies spec into a dashboard and get a stable URL back. Parameters, return shape, errors and scope rules.
updated: 2026-09-20
tools: [publish_dashboard]
---

Publishes a dashboard and returns its URL.

**A dashboard is published as a spec.** You send a Dashies spec and the server
compiles it to HTML, a data island and a refresh manifest, then seeds every
dataset from the connection and validates the result. A structurally broken or
silently wrong dashboard cannot be published this way.

**Body mode is not available.** Uploading raw bytes with `body` (and
`content_type`, `encoding`, or a hand-written `source_config` manifest) is
refused. The arguments below are still declared, because the path may return in
a future version, and they are documented so a refusal is not a surprise.

Dashboards published before this are unaffected: they keep serving and keep
refreshing, and reading, listing, deleting and restoring them are unchanged.

Behaviour hints for clients: not read-only, not idempotent, not open-world.

## Parameters

| Parameter | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | `<slug>` or `<slug>/<filename>`. A bare slug means `<slug>/index.html`. The workspace is added server-side, as the host. |
| `body` | string | no | **Not available.** A publish carrying raw bytes is refused. Still declared, because the path may return. |
| `content_type` | string | no | **Not available**, with `body`. Forbidden in spec mode, where a compiled spec is always `text/html`. |
| `encoding` | string | no | **Not available**, with `body`. Beside a `spec` it is accepted and ignored, a compiled spec being text. |
| `spec` | string | no | Spec mode, document sent inline. Capped at 5242880 bytes. |
| `spec_hash` | string | no | Spec mode, republish a document the server already holds. Cached for about an hour after any send. Pass that dry run's `report_id` beside it to have the report count what that dry run printed or counted. |
| `report_id` | string | no | With `spec_hash` only: the report id that document's dry run returned. The report then counts the warnings that dry run printed in full, or itself counted from an earlier one, instead of printing them again. Every warning prints when it is omitted, comes from another document or an earlier dry run, is more than about an hour old, or the dashboard's stored spec is now a different document. A dry run of the stored spec returns none. |
| `spec_edits` | array | no | Spec mode, exact-string edits against the stored spec. Requires `base_spec_hash`. |
| `dry_run` | boolean | no | Spec-only. Runs compile, validation and a read-only seed and writes nothing to the dashboard. Returns a `spec_hash` and, usually, a `report_id` to pass back beside it. Default `false`. |
| `base_spec_hash` | string | no | Spec-only lost-update guard. Required with `spec_edits`, where it also names the document. |
| `name` | string | no | Display name. Defaults to a title-cased slug on insert; preserved on republish. |
| `tags` | array | no | Preserved when omitted. `[]` clears. |
| `chart` | string | no | Preserved when omitted. |
| `workspace` | string | no | Workspace slug. Works in both modes. |
| `source_config` | object | no | **Not available.** The hand-written refresh manifest was only ever accepted beside a `body`, so it cannot be sent on its own. A spec's manifest is compiled from its datasets. Dashboards that already carry one keep refreshing, and `get_source_config` still reads it back. |

Exactly one of `spec`, `spec_hash` and `spec_edits` may name the document. All
three compile, validate and seed identically; the saving is transmission, never
checking.

## Returns

**Spec mode is the one you will see.** The body-mode receipt below is kept,
marked, because the arguments are still declared and the path may return; a
publish carrying raw bytes is refused before it can produce one.

Body mode returned the URL, the byte count and the resolved metadata:

```text
Published to workspace.
Slug: revenue-overview
Bytes: 48213
Name: Revenue Overview

Refresh: daily (manifest stored; the cube SQL re-runs on this schedule).
URL: https://acme.dashies.ai/revenue-overview
```

There is no `Visibility:` line any more: a dashboard opens for the members of the
workspace it lives in, so there was nothing left to report. A manual cadence reads
`Refresh: manual (manifest stored; no automatic schedule - refresh on demand).`
Auto-promote notes, seed notes and `Warning:` blocks are appended, separated by
blank lines.

Body mode also rode `structuredContent`:
`{ slug, filename, url, fileUrl, name, wasInsert }`, plus
optional `refresh: { frequency }` and `warnings: string[]`. Unreachable for the
same reason, and kept for the same one.

Spec mode returns human lines then a JSON block. This one is a publish by the
`spec_hash` and `report_id` a dry run returned, which is the taught flow, so its
warnings are counted rather than printed again:

```text
Published revenue-overview as mickey.
Dashboard URL: https://acme.dashies.ai/revenue-overview
Spec hash: 6f1c...
Datasets:
- revenue: its data stays with Dashies and is queried when someone opens the page, so the page does not have to carry it all, ...
Rows seeded: revenue 1000 (total unconfirmed)
Bytes: page 51204, data 18422 (body is 1% of the 5242880-byte publish limit)
First data: extracting now. The dashboard shows "updating" until it lands.
Since the dry run of this same document (spec_hash 6f1c...): 3 warnings and 1 check you owe are unchanged and not printed again, the oldest of them last printed in full at 2026-09-15T19:01:05.000Z. Nothing new was found. The JSON below still lists every warning and check, under `warnings` and `obligations`.

BEGIN_JSON
{"ok":true,"published":true,"url":"https://acme.dashies.ai/revenue-overview","slug":"revenue-overview","spec_hash":"6f1c...","datasets":[{"name":"revenue","plan":"..."}],"warnings":[...],"obligations":[...],"bytes":{"body":51204,"data":18422,"per_dataset":{"revenue":18310}},"seed_rows":[{"name":"revenue","seeded":1000,"available":null}],"assets":[],"first_data":"extracting_now","report_baseline":{"spec_hash":"6f1c...","dry_run_at":"2026-09-15T19:01:05.000Z","unchanged_warnings":[0,1,2],"unchanged_obligations":[0],"no_longer_present":0}}
END_JSON
```

A dry run opens with
`Dry run: <slug> compiled cleanly - nothing was published.` and reports
`published:false, url:null`. Under its spec hash it prints a `Report id:` line
when it could store its report, and the JSON carries the same value as
`report_id`, or `null`.

**The warning area is presentation over the lists the JSON carries in full.**

- Warnings to review, the checks you owe, and routine notes each get a block, in
  that order, headed by how many warnings the block stands for.
- The same warning on several datasets prints once, with a `Same for datasets:`
  line naming the rest.
- A dataset whose data stays with Dashies gets at most ONE routine note in
  place of the separate warnings about it, saying it publishes with NO data
  until a refresh reads the rows and how a page you wrote reads them. The JSON
  keeps the separate warnings that note stands for.
- A publish whose `First data:` line says the rows are already served
  (`unchanged`, or `loaded N rows`) prints no note about waiting for data, which
  on a page of managed tiles leaves no note at all, and a closing `Not printed:`
  line counts the notes it left out. `first_data` in the JSON names which
  `First data:` line was printed: `extracting_now`, `loaded`, `unchanged`,
  `no_new_extract` or `not_started`, or `null` on a dry run and on a publish
  that printed none.
- A publish that passes a dry run's `spec_hash` and that dry run's `report_id`
  is compared with that dry run's report. Identical warnings and checks are
  counted on the `Since the dry run of` line, which names when the oldest of
  them was last printed in full; what is new, or was last printed in full more
  than about an hour ago, prints; and `report_baseline` lists which items were
  unchanged. Everything prints when the `report_id` is missing, belongs to
  another document (a dry run of `spec_edits` returns its own) or to an earlier
  dry run of the same one, or is more than about an hour old, and when the
  dashboard's stored spec is now a different document than when that dry run
  ran. The comparison is kept as a digest of each warning with when its text was
  last printed, never the text, and a real publish discards it.

:::warning{title="Read the rows-seeded line in full"}
The receipt reports rows in and rows shipped per dataset. Equal counts render as
`mix 9 (total unconfirmed)`, not a bare `mix 9`, and the suffix is load-bearing:
where an engine reports no server-side total the server falls back to the length
of what it received, which always manufactures agreement. Only a genuine
shortfall gets the bare `3/6` form. Quote the line whole or not at all.
:::

## Errors

**Argument matrix**, returned before any work happens:

```text
provide exactly one of `spec`, `spec_hash`, or `spec_edits` (got `spec` + `spec_hash`): `spec` sends the document inline, `spec_hash` republishes a document the server already validated, and `spec_edits` patches the stored spec.
```

```text
provide either `body` or `spec`, not both: `spec` publishes the compiled file format, `body` publishes raw bytes.
```

The remaining matrix refusals, each verbatim:

```text
dry_run is only valid with `spec` (the file-format publish); a body publish always writes.
base_spec_hash is only valid with `spec` (the file-format publish's lost-update guard).
dry_run must be a boolean.
base_spec_hash must be a non-empty string (a spec_hash returned by get_dashboard_spec).
spec must be a non-empty string (the Dashies file-format YAML).
spec_hash must be a non-empty string (a spec_hash returned by a dry run, a previous publish, or get_dashboard_spec).
content_type is not allowed with `spec`: a compiled spec is always text/html.
source_config is not allowed with `spec`: the refresh manifest is compiled from the spec's datasets and source.
spec_edits requires base_spec_hash: the edits are applied to the spec with that hash, and it is also the lost-update guard. Read the current spec with get_dashboard_spec and pass the spec_hash it returns.
```

In the first two, `<noun>` is whichever of the three spec arguments you sent, so
`spec_edits` produces the same sentence naming `spec_edits`.

An edit whose `old_string` is not found, or is found more than once without
`replace_all`, refuses the whole publish and writes nothing. Include more
surrounding lines rather than retrying blind.

**A publish carrying raw bytes** is refused before anything is written, with the
argument named and the fix named:

```text
Publishing a raw HTML `body` is not available: a Dashies dashboard is published as a spec. Pass `spec` with the YAML document instead of `body`, and the server compiles the page, its numbers and its refresh settings from it. See the dashies authoring skill for the format, and validate each dataset's SELECT with validate_cube_sql first. Dashboards already published are unaffected: reading, listing, deleting and restoring them are unchanged.
```

Nothing is stored: no object, no row, and no tombstone.

**The body-mode conditions below are no longer reachable**, because that refusal
lands before any of them is evaluated. They are kept, marked, because the
arguments are still declared and the path may return.

| Condition | Text |
|---|---|
| Missing body | `body is required` |
| Missing content type | `content_type is required` |
| Bad base64 | `400 invalid base64 body: <err>` |
| Bad encoding | `400 invalid encoding "<enc>"` |
| Over the cap | `413 payload too large: encoded body is <n> bytes, maximum is 5242880` |
| Storage write failed | `r2 put failed: <err>` |
| Database write failed after the bytes landed | `db write failed after R2 put: <err>` |

Manifest shape errors come from the same validator the refresh cron mirrors, and
include `source_config must be a JSON object`,
`source_config.manifest_version must be 1, 2, 3, or 4`,
`source_config.connection must be a non-empty string`,
`source_config.cube_sql must be a non-empty string`,
`source_config.schedule must be one of manual/hourly/daily/weekly/monthly`, and
`source_config.timezone "<tz>" is not a valid IANA time zone (e.g. "America/Los_Angeles", "Europe/London", "UTC"). Omit it to default to UTC.`

A manifest that stores but fails to attach is a **partial success**:

```text
Published, but failed to attach the refresh manifest: <err>. The dashboard is live as a static page; retry the same publish to attach the manifest and enable refresh.
```

**Spec mode** failures print a count, then one line per finding in the shape
`- [<layer>] <path or (spec)>: <message> (<hint>)`:

```text
Publish failed - 1 error (nothing was published):
- [seed] datasets[0]: <the engine's own error> (the seed runs under the same time budget a scheduled refresh gets, so this cube did not finish in the time the cron would give it - publishing it would produce a dashboard that fails every refresh. If the warehouse was cold or paused, retry (it should be warm now); otherwise aggregate to a coarser grain or narrow the window. Nothing was published.)
```

The angle-bracketed part is the engine's message, which varies. The hint after it
is ours and is fixed per failure class; the one above is the `connection_timeout`
hint, and it is worth reading closely because **a spec publish is a dry run of
the refresh's clock**. The seed gets the cron's budget, not the more generous
authoring budget `validate_cube_sql` runs under, so a cube that validates in 90
seconds can still be refused here. That is the point: it would otherwise publish
cleanly and then time out on every scheduled refresh, forever.

**Being fixed per failure class means it carries no connection of its own, and
`aggregate to a coarser grain` there is the in-file remedy.** On a dataset whose
rows Dashies holds, reach for the narrower window instead: grouping those records
to what the dashboard reports at hands Dashies a summary to summarize, and a
count then comes back as the number of groups you made rather than the number of
records. See [Datasets](/reference/spec#datasets).

Layers are `arg`, `compile`, `seed`, `conflict`, `internal` and `write`.

**A slug the app would shadow is refused on the `arg` layer**, before anything
compiles. `pricing`, `auth`, `app`, `assets` and `api` are routes of the web app,
so a dashboard slugged with one would be hidden by the app on its own subdomain:

```text
slug "pricing" is reserved - it would be shadowed by the app at https://acme.dashies.ai/pricing; choose a different slug.
```

See
[Reserved first path segments](/reference/urls-and-slugs#reserved-first-path-segments).
THERE ARE THREE GUARD SITES AND THEY DO NOT ALL WORD IT ALIKE. Two are in the
workspace writers, `publishWorkspaceDashboardImpl` and `updateWorkspaceDashboardImpl`,
which share `reservedSlugRefusal` and end the sentence with no full stop. The third is
inline in the SPEC publish path under `isReservedSubdomainSlug`, and it ends with one.

**The receipt above is the third one**, because it is the only site this tool can now
reach: a raw body publish is refused before the workspace writer runs, so `publish_dashboard`
always takes the spec path. That is also why the paragraph above says the refusal comes on
the `arg` layer, which is a field only the spec path produces.

Renaming a dashboard **away from** a reserved slug is allowed, and that rename is one of the
two sites that word it without the stop.

The workspace writers also have a second form, without the URL, for a request naming a
workspace on a host that resolves none. The SPA never sends one.

Going over the byte cap reports the heaviest dataset by name, so you know which
one to shrink:

```text
the compiled dashboard is <n> bytes, over the 5242880-byte publish limit. The heaviest dataset is `<name>` (<n> bytes) - coarsen its grain or reduce its rows.
```

**`coarsen its grain` is the server's own wording and it assumes the in-file
shape.** Only what ships inside the file counts toward that cap, so a dashboard
whose datasets Dashies holds does not reach it through its data, and coarsening a
held dataset's grain would hand Dashies a summary to summarize rather than a
smaller file: it works each number out when a reader opens the page, so a
statement that has already aggregated is aggregated a second time. See
[Datasets](/reference/spec#datasets).

A `base_spec_hash` mismatch arrives under `code: "spec_conflict"`.

One spec failure keeps `published: true`, because the content did land:

```text
dashboard content was saved, but refresh installation failed - call get_dashboard_spec and republish with the returned spec_hash to complete.
```

Rate limited at 60 calls per user per minute, in both modes. See
[Rate limits](/reference/mcp-tools/conventions#rate-limits).

## Scope

**Both modes publish into a workspace.** Pass `workspace`, or use a
workspace-authorized connection. See
[Scope](/reference/mcp-tools/conventions#scope-which-workspace-a-call-acts-on).

## Check it worked

Open the `Dashboard URL` the receipt printed. If you attached a manifest, confirm
the `Refresh:` line names the cadence you asked for rather than `manual`, then
read the state back with
[`get_refresh_status`](/reference/mcp-tools/get-refresh-status).
