Reference
Shared rules
Paths, slugs, metadata semantics, connection resolution, scope, rate limits and retention. The rules every Dashies MCP tool shares.
These rules are enforced in one place in the server and therefore apply identically across tools. The per-tool pages name the rules that bite them and link back here rather than restating them.
Paths and slugs
You pass <slug>/<filename>. The workspace is added server-side from your
authorization, as the HOST; never include it in the path. A bare <slug> means
<slug>/index.html.
| Rule | Value | Error text |
|---|---|---|
| Slug charset | /^[a-z0-9-]+$/ | slug "<x>" must match [a-z0-9-] |
| Slug length | 64 characters | slug exceeds 64-char limit (got <n>) |
| Slug not empty | slug must not be empty | |
| Filename charset | /^[a-z0-9._-]+$/ | filename "<x>" must match [a-z0-9._-] |
| Path shape | exactly 1 or 2 segments | path "<p>": must be <slug>/<filename> (exactly two segments) |
| Path not empty | path must not be empty | |
| Version id | a UUID | version_id must be a valid uuid |
| Upload cap | 5242880 bytes (5 MiB) | 413 payload too large: ... |
| Spec cap | 5242880 bytes | spec is <n> bytes, over the 5242880-byte limit ... |
Slugs are unique per account, and aliases left behind by earlier renames share that same namespace. A slug that would be shadowed by the app itself is reserved and refused by name.
Dashboard URLs
The canonical form is a subdomain, not a path:
https://<workspace-slug>.dashies.ai/<slug>
Every success message the server emits uses that form. Renaming leaves the old slug as an alias that 301-redirects to the new canonical URL.
Dashboard metadata
name, tags and chart follow one rule set, shared by
publish_dashboard, update_dashboard and the spec publish. The distinction
that matters is omitted versus empty:
| You pass | Effect |
|---|---|
The field omitted, or null | Preserved. On republish the prior value survives. |
name: "" or whitespace | Rejected: name must be a non-empty string |
chart: "" | Rejected: chart must be a non-empty string |
tags: [] | Clears all tags. |
tags not an array | Rejected: tags must be an array of strings |
| A non-string tag entry | Rejected: tags[<i>] must be a string (got <typeof>), never silently filtered |
visibility present at all | Rejected: visibility is no longer accepted: public dashboards have been removed, so every dashboard is visible to its owner or, for a workspace dashboard, to that workspace's members. Omit the argument. |
There is no way to clear a name: omitting it preserves, and an empty string is
refused. That asymmetry is deliberate, because a nameless dashboard has nothing
to show in a list.
There is no visibility setting. A dashboard opens for the members of the workspace it lives in, and a signed-out visitor is redirected to sign in with a return path. Nothing is viewable anonymously, and none of this is plan-gated.
Connections
A connection names where a refreshable dashboard's SQL runs. Two kinds:
self, the built-in no-PII Dashies metrics view. Always available, needs no connection id, and is the default wheneverconnectionis omitted.- A warehouse connection you may use, named by its UUID. Warehouse connections are created in the Dashies web app, never through MCP, and require a paid plan.
Resolution runs in two steps, each with its own refusals.
Shape, before anything is looked up:
| Condition | Error text |
|---|---|
| Not a string | connection must be a string |
Neither self nor a UUID | connection "<x>" is not a valid connection; pass "self" (Dashies' own no-PII metrics) or a connection id from list_connections - a warehouse, or this workspace's uploaded-file source, which also needs an upload id saying which uploaded file to read. Warehouses are connected in the Dashies web app (Data sources). |
Usability, once it is looked up:
| Condition | Error text |
|---|---|
| Lookup failed in transport | Could not verify the connection: <err> |
| Unknown, foreign, wrong-scope or not a warehouse | Connection "<id>" was not found among your connections. Connect and manage warehouses in the Dashies web app (Data sources), then pass an active connection's id (from list_connections) here - or use "self" (Dashies' own no-PII metrics) with no connection id. |
| Found but not active | Connection "<label or id>" is not active (status: <status>). Test it in the Dashies web app (Data sources) and try again once it is active. |
One refusal covers four causes, on purpose
Unknown, someone else's, wrong-scope and not-a-warehouse all produce the same sentence. That is not vagueness: a distinguishable "exists but is not yours" would let anyone probe for the existence of a connection id.
A connection from one workspace is not enough for another's dashboard
A connection's scope is the immutable (owner, workspace_id) pair, fixed when it
was created, with no move path in either direction. A dashboard bound to a
connection created in a different workspace publishes fine and can then never
refresh, and dry_run does not catch it. Add the warehouse from inside the
workspace you are publishing into.
This warning used to be about a connection you owned personally. There is no such
connection: data_connections forbids the shape at the table, so every connection
belongs to a workspace and the mismatch that remains is between two of them.
Scope: which workspace a call acts on
Your authorization is locked to one workspace when you grant it, and that lock, not an argument, decides the default. Every dashboard lives in a workspace, so a grant that names none cannot act on a dashboard at all. The grant is in one of three states:
| Grant state | Behaviour |
|---|---|
| Workspace-locked | That workspace is used. A workspace argument must match its slug or the call is refused. |
| Locked to no workspace | Every dashboard tool is refused, and so is check_readiness. Re-authorize and choose a workspace. |
| Legacy (no lock) | A workspace argument is honoured; without one, every dashboard tool is refused. |
The refusals, verbatim:
Dashboards outside a workspace are retired, and this connection is authorized for
your personal account, so it cannot publish or act on anything. Re-authorize the
connection and choose the workspace it should use.
Dashboards outside a workspace are retired, and this connection names no workspace
to publish into. Pass `workspace` with a workspace slug you belong to, or
re-authorize the connection and choose the workspace it should use.
This connection is authorized for the "<locked-slug>" workspace and cannot act on
"<arg>". Re-authorize the connection and choose that workspace to use it there.
check_readiness sends none of those and has a sentence of its own, because it
takes no arguments at all. Telling it to pass workspace names a fix its caller
cannot perform, and telling a legacy grant it is authorized for a personal account
would be false, so one clause covers both grant states:
Dashboards outside a workspace are retired, and this connection is not authorized
for any workspace, so it cannot publish a dashboard anywhere. Re-authorize the
connection and choose the workspace it should use.
On the legacy path a workspace argument can additionally fail with
workspace must be a non-empty slug string, workspace "<slug>" not found, or
not a member of workspace "<slug>".
Every dashboard-scoped tool accepts a workspace target.
trigger_refresh used to be the one
exception; it no longer is.
Two tools take no workspace argument because they are connection-scoped rather
than dashboard-scoped (introspect_schema
and validate_cube_sql), and
list_connections derives its scope
purely from the grant.
Six tools report a dashboard they cannot find with one shared sentence,
No dashboard named "<slug>" in that workspace.: get_refresh_status, get_source_config, get_dashboard_spec,
set_refresh_schedule, verify_dashboard and derive_dashboard_spec.
That is an enumeration rather than a rule, and the list is the whole claim: five other
tools word it their own way, and each one's own page prints its sentence.
list_dashboard_versions and restore_dashboard_version answer dashboard not found,
get_dashboard answers 404 not found: "<slug>/<file>" under the <workspace> workspace,
trigger_refresh answers No dashboard found with slug "<slug>" in this workspace., and
update_dashboard answers dashboard not found for slug "<slug>". The lookup is membership-gated,
and a dashboard that genuinely does not exist produces the same sentence as one you
cannot see, which is deliberate: a distinguishable refusal would be an existence
oracle keyed by slug.
A SECOND CLASS PASSES THE DATABASE'S OWN REFUSAL THROUGH RATHER THAN WORDING ONE, and that
is the boundary of the list of five: it enumerates tools whose own text names a missing
dashboard. delete_dashboard wraps the delete RPC's refusal, so a slug that names nothing reads
delete rejected: dashboard not found for slug "<slug>", and update_dashboard's RENAME path
wraps its own rename RPCs' refusal inside rename rejected: <msg>. Neither is a sixth wording:
dashboard not found for slug "<slug>" is one sentence the database raises on several paths,
and it is what update_dashboard's metadata edit already prints without a wrapper.
update_dashboard is therefore in BOTH lists, and that is the reason these classes are about
TEXT rather than about tools. Its metadata edit builds the sentence itself, so it belongs
among the five; its rename passes the database's through, so it belongs here. A tool can do
both, which is why "which tools report a missing dashboard" has no single answer and "which
text does a caller see" does.
This page used to document a divergence here
Five of those tools said in your account while set_refresh_schedule alone
said in that workspace, and this page recorded that as a known quirk. They share
one sentence now.
list_connections has a refusal of its own, because it lists connections rather
than acting on a dashboard and so cannot honestly claim it is unable to publish:
Data sources belong to a workspace, and this MCP connection is not authorized for
any workspace, so there is no space whose data sources it can list. Re-authorize it
and choose the workspace it should use.
Rate limits
Four independent counters, each a fixed ceiling over a 60-second window:
| Surface | Ceiling per minute | Keyed on |
|---|---|---|
publish_dashboard, both modes | 60 | your user id |
validate_cube_sql | 120 | your user id |
trigger_refresh | 20 | your user id |
Client registration (POST /register) | 30 | client IP |
A throttled tool returns:
Rate limit exceeded for <tool name>: too many requests. Wait about 60 seconds and try again.
A throttled registration is not a tool error but an HTTP 429 carrying
retry-after: 60 and the body
{"error":"rate_limited","error_description":"too many client registrations, please retry shortly"}.
trigger_refresh is limited twice: this 20-per-minute budget, plus an
authoritative budget on the refresh service itself, which produces a different
sentence (Too many refresh triggers right now. Wait about a minute and try again.).
That second budget is keyed on the WORKSPACE, so everyone with a seat in it draws on one shared allowance. Your own budget still applies as well, so moving between workspaces does not buy you extra capacity.
Rate limits fail open
If the limiter itself is unavailable, requests are allowed rather than refused. That is the deliberate inverse of the authorization gates, which fail closed: a throttle exists to control cost, so its outage must not look like a permission error.
Version retention
| Rule | Value |
|---|---|
| Automatic snapshots kept per dashboard | 20 (oldest unnamed ones pruned) |
| Named versions kept per dashboard | 30, exempt from the autosave prune |
| Versions returned by one list call | 50, with no pagination |
Naming a version with
update_dashboard_version is
what protects it from the prune. The read limit of 50 is exactly the two caps
added together, which is why the list needs no cursor.
Authorization
OAuth 2.1 with PKCE and Dynamic Client Registration. There are no tokens to
copy, paste or rotate. Your client discovers the metadata off a 401,
registers itself, opens your browser, and you sign in with Google once.
The steps behind that, and where each can fail:
- A tool call returns
401with aWWW-Authenticateheader. - The client fetches
/.well-known/oauth-authorization-serverand/.well-known/oauth-protected-resource. - The client registers at
POST /register. Unauthenticated, so IP-rate-limited at 30 per minute. - The client opens
GET /authorize. The request is stored for 30 minutes and your browser is sent to the Dashies consent screen. - The consent screen shows the real client name, the exact redirect URI in use, and the requested scope, so a non-loopback redirect URI is visible to you before you approve.
- You sign in, optionally pick a workspace, and click Allow access.
- The server verifies the token, mints a session dedicated to this grant, and only then consumes the request. A transient failure leaves it retryable, so clicking Allow access again is safe.
- The client exchanges the code at
POST /tokenand stores the access token in your OS keychain.
Clicking Cancel returns error=access_denied with
error_description=The user declined the authorization request.
Token lifetime. The MCP access token lives at most 50 minutes and is always clamped to expire before the underlying session it carries. On expiry the client refreshes silently; each grant owns its own rotating chain, so two AI clients and your browser tab cannot invalidate each other. That is why a working connection does not re-authorize every hour.
If a tool returns an auth error, the client re-triggers the browser flow. If it does not, retrying the call prompts it.
Check it worked
Ask your AI tool for your list of connections. Three outcomes, all informative:
a list means a live authorization and a paid plan; You have no warehouse connections. means a live authorization with none added yet; a browser window
means the handshake is running and you are about to be authorized.