Reference / Publishing
publish_dashboard
Compile a Dashies spec into a dashboard and get a stable URL back. Parameters, return shape, errors and scope rules.
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:
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:
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, orloaded N rows) prints no note about waiting for data, which on a page of managed tiles leaves no note at all, and a closingNot printed:line counts the notes it left out.first_datain the JSON names whichFirst data:line was printed:extracting_now,loaded,unchanged,no_new_extractornot_started, ornullon a dry run and on a publish that printed none. - A publish that passes a dry run's
spec_hashand that dry run'sreport_idis compared with that dry run's report. Identical warnings and checks are counted on theSince the dry run ofline, 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; andreport_baselinelists which items were unchanged. Everything prints when thereport_idis missing, belongs to another document (a dry run ofspec_editsreturns 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.
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:
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.
provide either `body` or `spec`, not both: `spec` publishes the compiled file format, `body` publishes raw bytes.
The remaining matrix refusals, each verbatim:
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:
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:
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>):
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.
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:
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.
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:
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.
A base_spec_hash mismatch arrives under code: "spec_conflict".
One spec failure keeps published: true, because the content did land:
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.
Scope
Both modes publish into a workspace. Pass workspace, or use a
workspace-authorized connection. See
Scope.
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.