All routes run on the Node runtime. Every one resolves identity with
requireIdentity() and authorizes through can() — see
Authorization model.
| Route |
Method |
Min role |
Notes |
/api/dashboards |
GET |
viewer |
List dashboards. ?workspaceId= and ?editable=true only narrow the caller’s own workspaces — the candidates come from the claims and each is re-checked with can() |
/api/dashboards |
POST |
editor |
Create; validates the spec and derives the workspace from trusted sources |
/api/dashboards/[id] |
GET |
viewer |
Current version with its spec |
/api/dashboards/[id] |
PUT |
editor |
Appends a new immutable version |
/api/dashboards/[id] |
PATCH |
editor |
Metadata. description and tags are written to the row in place; a title appends a version, because the spec owns the name |
/api/dashboards/[id] |
DELETE |
owner / source-admin |
Soft delete |
/api/dashboards/[id]/duplicate |
POST |
viewer + editor |
Copies the current spec into a new dashboard at version 1, titled "… (copy)". Re-validates the spec, so a copy of a dashboard whose source was tombstoned fails loudly |
/api/dashboards/[id]/favorite |
PUT / DELETE |
viewer |
Star or unstar it for the caller. No body: the subject is always the session’s own |
/api/dashboards/[id]/export |
GET |
viewer |
Downloads the current spec as a JSON file (Content-Disposition: attachment). Carries source ids only — no workspace, author, or connection detail |
/api/dashboards/import |
POST |
editor |
Creates a dashboard at version 1 from an exported file. The target workspace is a request field re-checked by can(); source ids are re-pointed by an explicit mapping and any still unresolved refuse the whole import |
/api/dashboards/[id]/stream |
GET |
viewer |
SSE deltas, cookie-authenticated |
/api/dashboards/[id]/chat |
POST |
viewer |
Read-only chat with a guarded runQuery tool. Rate limited and budgeted. The turn is persisted for the caller’s own subject and the request’s abort signal cancels the model call |
/api/dashboards/[id]/chat |
GET |
viewer |
The caller’s own stored conversation on this dashboard, bounded by CHAT_HISTORY_MAX_MESSAGES / CHAT_HISTORY_RETENTION_DAYS. The subject comes from the session, never from the request |
/api/dashboards/[id]/chat |
DELETE |
viewer |
Forget the caller’s own conversation on this dashboard. Nobody else’s |
A template is a reusable Panel or Dashboard spec, workspace-scoped and
authorized exactly like the dashboards it is made of — there is no template:*
vocabulary in can(), because a template carries no capability its dashboard
did not.
| Route |
Method |
Min role |
Notes |
/api/templates |
GET |
viewer |
Templates saved in a workspace. ?kind=panel|dashboard narrows; ?sourceId= additionally returns the built-in golden-signal starters parameterized by that source’s catalog |
/api/templates |
POST |
editor |
Save a panel or dashboard as a template. The body is put through the same resolveAndValidateDashboard a save uses, so every statement is re-guarded and the workspace must match the one derived from the sources. 409 when the workspace already has that name |
/api/templates/[id] |
DELETE |
owner / source-admin |
Hard delete — instantiating a template copies its spec, so nothing points back at the row |
There is deliberately no instantiate route. Applying a template re-points
its panels at a source the user picks, re-checks each statement through
/api/sql/validate, and then goes out through the ordinary create or save,
so a dashboard built from a template is indistinguishable from one built by
hand.
| Route |
Method |
Min role |
Notes |
/api/generate |
POST |
editor |
Streams a validated dashboard, panel, or explore-panel spec. Authorized against the workspace owning the selected source. Refuses with a 400 when that source’s catalog was never refreshed or names nothing that still exists. Rate limited and budgeted |
/api/query |
POST |
editor |
One-shot guarded query for preview and Explore |
/api/sql/validate |
POST |
editor |
Runs the SQL guard against a source’s catalog without executing. Always 200; the verdict is { ok, error? } |
/api/generation-log |
GET |
source-admin |
The redacted prompt/spec pairs every generation leaves behind. Workspaces come from the caller’s claims, so ?workspaceId= narrows and can never widen; a viewer or editor reads an empty list rather than a 403. ?limit= is clamped |
| Route |
Method |
Min role |
Notes |
/api/sources |
GET |
viewer |
List sources in a workspace, each with its catalog health |
/api/sources |
POST |
source-admin |
Create |
/api/sources/generate |
POST |
editor |
Streams a validated SourceDraft — never credentials. Rate limited and budgeted |
/api/sources/[id] |
GET/PUT/DELETE |
source-admin |
Delete tombstones when referenced |
/api/sources/[id]/impact |
GET |
source-admin |
Dashboards and panels currently referencing the source, scoped to its workspace |
/api/sources/[id]/test |
POST |
source-admin |
Connectivity, latency, server and role identity, a read-only proof, and per-table reachability. All of it inside one rolled-back read-only transaction |
/api/sources/[id]/refresh |
POST |
source-admin |
Re-introspect the catalog; records freshness and any allowlisted table the database no longer has |
| Route |
Method |
Min role |
Notes |
/api/search |
GET |
viewer |
What the command palette searches. Dashboards and sources across every workspace the caller can already reach, taken from the claims — there is no workspace parameter to widen it. A source is projected to its id, name, workspace and whether the caller may manage it; the connection config and the catalog never leave the server |
| Route |
Method |
Min role |
Notes |
/api/me |
GET |
signed in |
The caller’s own subject, display name, email, platform-admin flag and workspace roles, all from the session. No parameters, so it can only describe the identity that asked. The name and email are display-only and never reach can() |
/api/me/preferences |
GET / PATCH |
signed in |
The caller’s own preferences: time zone, clock, start page and dashboard list defaults. PATCH merges a partial object; an unknown key or an invalid value is a 400 naming the field, and a start dashboard must be one the caller can view. No subject parameter, so nobody reads or writes another person’s row |
| Route |
Method |
Min role |
Notes |
/api/workspaces/[id]/limits |
PATCH |
platform admin |
Set or clear the workspace’s ratePerMinute and dailyTokenBudget overrides in workspace_limits. A present key is written, null inherits the environment’s value again, 0 disables the limit, and an absent key is left alone. Gated on workspace:limits, which no workspace role grants. Each change writes a workspace_limits.changed log line with the before and after values. Answers with the workspace’s effective limits and today’s usage; the next model call uses them, with no restart |
| Route |
Method |
Notes |
/api/auth/login |
GET |
Begins the OIDC authorization-code flow |
/api/auth/callback |
GET |
Verifies the token, mints the session cookie |
/api/auth/logout |
GET |
Clears the session cookie |
These three are outside the session: a probe and a scraper do not hold a
cookie.
| Route |
Method |
Access |
Notes |
/api/health |
GET |
open |
Liveness. Always 200 while the process serves; carries the build identity |
/api/ready |
GET |
open |
Readiness. Does I/O and fails while draining. See Health and readiness |
/api/metrics |
GET |
token and/or CIDR |
Prometheus exposition format. 404 until configured. See Prometheus metrics |
Statement-level SQL failures return 400 with the real message so an editor
can correct and retry. Connection and infrastructure failures return a generic
500 and are never surfaced. This split is
invariant 16.
| Status |
Meaning |
| 400 |
Invalid request, invalid spec, rejected SQL, or a failed statement |
| 401 |
No valid session |
| 403 |
Authenticated but not authorized for the action |
| 404 |
Resource not found |
| 409 |
Source is tombstoned, or a template name is already taken in the workspace |
| 429 |
A model-backed route hit the workspace’s rate limit or token budget; the message says which and when it resets, and Retry-After is set. See LLM rate limits and budgets |
| 500 |
Infrastructure failure — deliberately opaque |
Every error body is { error, kind, requestId }. kind is one of
validation, statement, authorization, not_found, conflict,
rate_limit, or infrastructure, and it is what a client presents from — a
400 is both a rejected body and a failed statement, and only the route knows
which. requestId is the same value as the x-request-id header, repeated in
the body so the opaque path has something the user can quote back.