Skip to content

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 handle is added server-side from your authorization; never include it. A bare <slug> means <slug>/index.html.

RuleValueError text
Slug charset/^[a-z0-9-]+$/slug "<x>" must match [a-z0-9-]
Slug length64 charactersslug exceeds 64-char limit (got <n>)
Slug not emptyslug must not be empty
Filename charset/^[a-z0-9._-]+$/filename "<x>" must match [a-z0-9._-]
Path shapeexactly 1 or 2 segmentspath "<p>": must be <slug>/<filename> (exactly two segments)
Path not emptypath must not be empty
Version ida UUIDversion_id must be a valid uuid
Upload cap5242880 bytes (5 MiB)413 payload too large: ...
Spec cap5242880 bytesspec 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://<your-handle>.dashies.xyz/<slug>
https://<workspace-slug>.dashies.xyz/<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, chart and visibility follow one rule set, shared by publish_dashboard, update_dashboard and the spec publish. The distinction that matters is omitted versus empty:

You passEffect
The field omitted, or nullPreserved. On republish the prior value survives.
name: "" or whitespaceRejected: name must be a non-empty string
chart: ""Rejected: chart must be a non-empty string
tags: []Clears all tags.
tags not an arrayRejected: tags must be an array of strings
A non-string tag entryRejected: tags[<i>] must be a string (got <typeof>), never silently filtered
visibility other than public or privateRejected: visibility must be "public" or "private"

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.

New dashboards default to public personally and private (members-only) in a workspace. Visibility is not plan-gated in either direction.

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:

ConditionError text
Not a stringconnection must be a string
Neither self nor a UUIDconnection "<x>" is not a valid connection; pass "self" (Dashies' own no-PII metrics) or the id of a warehouse connection you own. Connect and manage warehouses in the Dashies web app (Connections); list_connections returns their ids.

Usability, once it is looked up:

ConditionError text
Lookup failed in transportCould not verify the connection: <err>
Unknown, foreign, wrong-scope or not a warehouseConnection "<id>" was not found among your connections. Connect and manage warehouses in the Dashies web app (Connections), 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 activeConnection "<label or id>" is not active (status: <status>). Test it in the Dashies web app (Connections) 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 you own is not enough for a workspace 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 workspace dashboard bound to a personal connection publishes fine and can then never refresh, and dry_run does not catch it. If you are publishing into a workspace, add the warehouse from inside that workspace first.

Scope: personal and workspace

Your authorization is locked to one target when you grant it, and that lock, not an argument, decides the default. The grant is in one of three states:

Grant stateBehaviour
Workspace-lockedThat workspace is used. A workspace argument must match its slug or the call is refused.
Personal-lockedPersonal. Any non-empty workspace argument is refused.
Legacy (no lock)A workspace argument is honoured; otherwise personal.

The refusals, verbatim:

This connection is authorized for your personal account only and cannot act on a
workspace. Re-authorize the connection and choose the workspace to use it there.
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.

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>".

Seventeen of the eighteen tools accept a workspace target. trigger_refresh is the single exception and refuses one with:

Triggering a refresh on demand isn't available for workspace dashboards yet - it
works on personal dashboards only.

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.

Some refusals say 'in your account' even on a workspace target

The four slug-keyed readers report a missing workspace dashboard as No dashboard named "<slug>" in your account. The lookup is correct and membership-gated; only the wording is personal. set_refresh_schedule is the one that words it properly, with No dashboard named "<slug>" in that workspace.

Rate limits

Four independent counters, each a fixed ceiling over a 60-second window:

SurfaceCeiling per minuteKeyed on
publish_dashboard, both modes and both scopes60your user id
validate_cube_sql120your user id
trigger_refresh20your user id
Client registration (POST /register)30client 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 per-owner budget on the refresh service itself, which produces a different sentence (Too many refresh triggers right now. Wait about a minute and try again.).

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

RuleValue
Automatic snapshots kept per dashboard20 (oldest unnamed ones pruned)
Named versions kept per dashboard30, exempt from the autosave prune
Versions returned by one list call50, 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:

  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.

Last updated 2026-08-04