---
title: Shared rules
description: Paths, slugs, metadata semantics, connection resolution, scope, rate limits and retention. The rules every Dashies MCP tool shares.
updated: 2026-08-27
---

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:

```text
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 whenever `connection` is 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.` |

:::note{title="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.
:::

:::danger{title="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:

```text
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.
```

```text
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.
```

```text
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:

```text
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`](/reference/mcp-tools/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`](/reference/mcp-tools/introspect-schema)
and [`validate_cube_sql`](/reference/mcp-tools/validate-cube-sql)), and
[`list_connections`](/reference/mcp-tools/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.

:::note{title="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:

```text
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:

```text
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.

:::note{title="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`](/reference/mcp-tools/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:

1. A tool call returns `401` with a `WWW-Authenticate` header.
2. The client fetches `/.well-known/oauth-authorization-server` and
   `/.well-known/oauth-protected-resource`.
3. The client registers at `POST /register`. Unauthenticated, so IP-rate-limited
   at 30 per minute.
4. The client opens `GET /authorize`. The request is stored for 30 minutes and
   your browser is sent to the Dashies consent screen.
5. 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.
6. You sign in, optionally pick a workspace, and click **Allow access**.
7. 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.
8. The client exchanges the code at `POST /token` and 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.
