For a narrative walkthrough of a single panel — generation → validation → storage → execution → rendering — see
PANEL_LIFECYCLE.md. This document is the invariant reference; that one follows one panel through them.
Holotable turns a natural-language prompt into a live monitoring dashboard. A language model authors a validated visualization spec (SQL + chart config) — it never touches, produces, or sees metric data. The server executes the guarded SQL against TimescaleDB and streams deltas to the browser.
Prompt ─▶ /api/generate ─▶ LLM (streamObject) ─▶ Zod IR spec (validated)
│ save
TimescaleDB (immutable versions)
Viewer ─▶ SSE /stream ─▶ shared in-process poller ─▶ guarded SQL ─▶ TimescaleDB
└─▶ deltas ─▶ ECharts merge
| Concern | Location |
|---|---|
| Shared IR (single Zod contract) | src/lib/ir.ts |
| Group parsing / identity | src/lib/auth/claims.ts |
Centralized authorization (can) |
src/lib/auth/authorize.ts |
| Session token (JWKS RS256 / dev HS256) | src/lib/auth/session.ts |
| Config store (Postgres) | src/lib/db/pg.ts, src/lib/db/repo.ts |
| Source registry (safe config + secret_ref) | src/lib/registry.ts |
| Catalog (metadata-only prompt) | src/lib/timescaledb/catalog.ts |
| SQL safety + time injection | src/lib/sql/safety.ts |
| Metrics execution (read-only) | src/lib/timescaledb/client.ts |
| LLM generation | src/lib/ai/provider.ts, src/lib/ai/generate.ts |
| Dashboard chat (read-only, tool-calling) | src/lib/ai/chat.ts, src/app/api/dashboards/[id]/chat/route.ts |
| Shared poller | src/lib/poller/registry.ts |
| Charts (setOption merge) | src/components/charts/* |
- LLM runs exactly once — only on author/create/edit, never on view or on a poller tick. Viewing and ticking replay the stored spec.
- LLM emits a validated spec, never data.
streamObjectis bound to the shared IR (DashboardGenerationSchema). Output is re-parsed with Zod before it is trusted. - Immutable, versioned specs. Each save inserts a new
dashboard_versionsrow containing the whole spec asjsonb. Specs are never mutated in place. - Panels carry only a stable
sourceId. No connection details, hosts, or credentials live in a panel. The registry owns the safe connection config, the catalog (table/column allowlist), and asecret_ref. - Credentials resolve from the environment via
secret_ref(TS_METRICS→TS_METRICS_USERNAME/TS_METRICS_PASSWORD). They are never stored in the database and never leave the server. The resolved user is the read-only TimescaleDB role. - Referenced sources are tombstoned, not hard-deleted. Deleting a source
that is referenced by any panel sets
tombstoned_at; panels then surface a tombstone state instead of silently breaking. - All model SQL is untrusted.
validateSqlenforces SELECT/WITH-only, a single statement, no comments, a keyword/table-function denylist, a catalog-table allowlist, and a ban on time / non-deterministic functions. - The server owns the time range.
buildExecutablePlanwraps the validated query as a subquery and injectsfrom/toon the declaredtimeFieldvia bound parameters, plus a read-only transaction and execution-time and row limits. The model cannot filter time or influence resource usage. Each execution also pinssearch_pathto the source's configured schema (a validated bare identifier) pluspublic, so unqualified table names resolve only against the allowlisted schema. - The catalog prompt is metadata only — table and column names/types for a single selected, authorized source per call. No sample rows are sent.
- One poller per dashboard shared across independently authorized
subscribers. Each browser opens one
EventSource. The poller executes each panel once per tick, tracks a per-panel delta cursor, and broadcasts only newer rows (append) or full snapshots (replace). - Charts merge, never recreate.
EChartinitializes once and appliessetOption(..., { notMerge: false }); the browser keeps a bounded rolling window (MAX_WINDOW_POINTS). - SSE is cookie-authenticated. The stream handler verifies the session cookie and re-authorizes the dashboard's workspace before subscribing.
- OKLCH design tokens are resolved to RGB/hex (
src/lib/color/oklch.ts) before being handed to ECharts, which cannot parseoklch(). - Every request is authorized from the validated identity. The source is
re-resolved and re-authorized on every execution (including each poller
tick).
can()inauthorize.tsis the only decision point and the only place the platform-admin bypass applies. - Dashboard chat is read-only and preserves every invariant above.
/api/dashboards/[id]/chatauthorizesdashboard:viewand exposes the model a singlerunQuerytool. The tool is scoped to the sources the dashboard already references and the caller may use (each re-resolved and re-authorized; unavailable ones are silently omitted, mirroring the poller's tombstone handling), runs the samevalidateSql→buildExecutablePlan→executePlanpipeline, injects the dashboard's own time range, caps rows (MAX_TOOL_ROWS) and model↔tool steps, and cannot mutate the dashboard. The model still authors SQL, never data. - Statement errors are actionable; infra errors are opaque.
executePlanraisesQueryExecutionErrorfor Postgres statement-level failures (SQLSTATE codes: bad column, syntax, type mismatch, timeout, missingtimeField). Routes translate it to a400with the real message so an editor can fix and retry; connection/socket failures stay a generic500and are never surfaced.
Roles come exclusively from the token's groups claim (Keycloak group paths):
/workspaces/{workspaceId}/viewer # read dashboards
/workspaces/{workspaceId}/editor # create/update/generate dashboards
/workspaces/{workspaceId}/source-admin # manage sources; delete dashboards
/platform-admins # global admin (single sanctioned bypass)
- Highest role wins within a workspace (
viewer < editor < source-admin). - Authorization is never derived from a workspace id in a request body. The
workspace is taken from a trusted, already-scoped resource
(
source.workspaceId,dashboard.workspaceId) or, for list/create, checked against the identity for the requested workspace. - Dashboard list/get → viewer; create/update/generate → editor; delete → owner, source-admin, or platform-admin.
- Source CRUD / test / refresh → source-admin.
The provider and model are environment-selected; no model catalog or
model-specific data is baked into the code (src/lib/ai/provider.ts):
AI_PROVIDER=gateway— the bareAI_MODELstring is routed by the AI SDK Gateway (AI_GATEWAY_API_KEY).AI_PROVIDER=openai-compatible— an OpenAI-compatible endpoint viaOPENAI_BASE_URL+OPENAI_API_KEY. Defaults to the Responses API (/responses); setOPENAI_API=chatto use the Chat Completions API (/chat/completions) for providers that only expose that surface. Examples of compatible providers:- OpenRouter:
OPENAI_BASE_URL=https://openrouter.ai/api/v1with an OpenRouter API key; use any OpenRouter model slug asAI_MODEL(e.g.openai/gpt-4o-mini). Uses the default Responses API — do not setOPENAI_API=chat, which breaks OpenRouter. - OpenCode Zen / Go: e.g.
OPENAI_BASE_URL=https://opencode.ai/zen/go/v1with the key provided by OpenCode, plusOPENAI_API=chat. Use a bare model id asAI_MODEL(e.g.kimi-k2.7-code) — OpenCode does not usevendor/modelslugs like OpenRouter, so a prefixed id such asopencode-go/kimi-k2.7-codeis rejected withModelError: Model ... is not supported. QueryGET <base-url>/modelsfor the exact ids. Only models that expose an OpenAI-compatible/chat/completionsinterface are supported via this path.
- OpenRouter:
Choosing the concrete provider/model is deliberately left to the deployment.
AI_MODEL must be set; there is no default model.
The poller lives in the Node process (src/lib/poller/registry.ts). It is
correct and efficient for a single app instance: one poller per dashboard,
shared by all subscribers on that instance. Running multiple app replicas would
create one poller per replica (duplicated TimescaleDB polling; no cross-instance
delta sharing). To scale horizontally, move the poller behind a shared runtime
(e.g. a dedicated poller service or a pub/sub fan-out such as Redis) and have
web instances subscribe to it rather than poll directly. The delta-cursor and
broadcast logic (computeDelta, the executor abstraction) are already isolated
to make that extraction straightforward.
sources— registry: safeconfig(jsonb),secret_ref,workspace_id,tombstoned_at.dashboards—workspace_id,title,created_by,deleted_at.dashboard_versions— append-only:dashboard_id,version,spec(jsonb).
metrics.http_requestsis a hypertable containing raw request events (seetimescaledb/init/001_schema.sql).- A continuous aggregate pre-aggregates per-minute request, error, duration, and byte statistics; a seven-day retention policy removes old raw chunks.
- A read-only role is created by
timescaledb/init/002_readonly_user.sh; the app only ever connects as this user via the sourcesecret_ref.