AI-powered dashboard builder for developers. Connect a database, type a natural language prompt, and Claude introspects your schema, writes a query plan, executes it, and renders charts and tables — all in real time.
Designed to be embedded in your own app. You bring the database and the Anthropic API key.
- Connect a datasource — add a Postgres connection string via the UI or API
- Prompt the agent — "show me total revenue by month for the last year"
- Watch it work — Claude introspects the schema, plans the queries, executes them, picks chart types, and streams results back as server-sent events
- See your dashboard — charts, tables, and metric cards render as each query completes
- Iterate — follow-up prompts amend the existing plan rather than starting over
The query plan is visible and editable. Variable values (date ranges, filters) are first-class — changing a variable re-runs only the affected nodes.
Who is responsible for what in the system.
graph TB
subgraph USER["User"]
U1[Connect datasource]
U2[Create dashboard & variables]
U3[Set variable values]
U4[Type prompt in ChatPanel]
U5[Rename / delete element]
end
subgraph AI["AI Agent (Claude)"]
A1[introspect_schema]
A2[summarize_schema]
A3[generate_query_plan]
A4[execute_query]
A5[render_element]
A6[amend_query_plan]
end
subgraph CODE["Code"]
C1[Stream SSE events to client]
C2[Inject schema snapshot into system prompt]
C3[Substitute variable tokens into query strings]
C4[Apply post_filters to result rows]
C5[Compute TanStack Query cache key]
C6[Render charts via Recharts]
C7[Encrypt datasource credentials]
C8[Throttle on Anthropic rate limit headers]
end
U4 -->|prompt| AI
AI -->|tool calls| CODE
U3 -->|variable values| C5
C5 -->|cache miss| C3
C3 -->|rows| C4
C4 -->|clean rows| C6
What happens end-to-end when a user sends a prompt.
sequenceDiagram
participant User
participant Client as React Client
participant Server as Hono Server
participant DB as SQLite
participant Claude as Claude AI
participant DS as Datasource
User->>Client: types prompt, hits Send
Client->>Server: POST /api/agent/run (SSE)
Server->>DB: load datasources, elements, active plan
Server->>DB: load latest schema snapshot per datasource
Note over Server: build system prompt with schema context,<br/>element titles + queryNodeIds, active plan ID
Server->>Claude: system prompt + messages
loop Agentic loop — repeats until end_turn
Claude-->>Server: tool_start
Server-->>Client: SSE tool_start
alt introspect_schema
Server->>DS: fetch live schema
Server->>DB: persist schema_snapshot
else generate_query_plan
Server->>DB: persist query plan
else amend_query_plan
Server->>DB: merge new nodes into existing plan
else execute_query
Server->>DS: run query
Note over Server: substitute variable tokens,<br/>apply post_filters, add _count columns
else render_element
Server->>DB: upsert dashboard_element row
end
Server-->>Client: SSE tool_end (with result)
Client->>Client: update TanStack Query cache
end
Server-->>Client: SSE done
Client->>Client: ElementRenderer fetches data → Recharts renders
How dashboard variables connect to query execution and chart rendering.
flowchart TD
subgraph setup["Setup — done once by User + AI"]
DV["User defines variable\n(e.g. continent: multi_select\noptions: Africa, Europe…)"]
QN["AI writes query node\nwith post_filter referencing variable\nor variable token in query string"]
VR["AI sets variableRefs on node\nvia render_element or amend_query_plan"]
end
subgraph runtime["Runtime — every variable change"]
UI["User changes variable value\n(e.g. selects Europe)"]
RV["Code filters variableValues\nto only keys in variableRefs"]
QK["TanStack Query key changes\n→ cache miss → re-fetch"]
EX["GET /api/agent/execute\n(re-runs query node)"]
PS["Code substitutes variable tokens\nor filters array via post_filter\nauto-adds field_count column"]
CH["ElementRenderer re-renders chart"]
end
subgraph series["Series variable — client-side only, no refetch"]
SV["chart.seriesVariable set\n(e.g. wx: multi_select)"]
KE["wx excluded from query key\n→ variable change never triggers refetch"]
AY["activeYAxes filters yAxes\nclient-side using current value"]
end
DV --> QN --> VR
VR --> RV
UI --> RV --> QK --> EX --> PS --> CH
UI --> AY
SV --> KE --> AY
How an element moves from prompt to rendered chart, and what can change it after.
flowchart LR
P["User types prompt"] --> AL["Agent loop runs"]
AL --> GP["generate_query_plan\nAI decides nodes, queries,\ntransforms, post_filters"]
GP --> EQ["execute_query\nCode runs query,\napplies transforms + filters"]
EQ --> RE["render_element\nAI picks vizType + axes\nCode upserts dashboard_element row"]
RE --> ER["ElementRenderer\nfetches data on mount\nRecharts renders"]
ER --> CH{"User or AI\nwants a change?"}
CH -->|"User: rename / delete"| API["PATCH or DELETE\n/api/dashboards/:id/elements/:elementId"]
CH -->|"User: follow-up prompt"| AM["amend_query_plan\nmerges updated nodes\nre-renders affected elements"]
CH -->|"User: variable changes"| VF["Variable flow\n(see above)"]
API --> ER
AM --> EQ
| Package | Description |
|---|---|
@dashagent/server |
Mountable Hono route handler. Brings the agent, DB, and datasource adapters. |
@dashagent/react |
React components and hooks: ElementRenderer, ChatPanel, useAgentStream. |
@dashagent/shared |
Shared TypeScript types and Zod validators. Bundled into the above two packages — not published separately. |
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { mountDashAgent, runMigrations } from '@dashagent/server'
const app = new Hono()
mountDashAgent(app) // mounts all routes under /api by default
await runMigrations() // creates the DashAgent schema in your DATABASE_URL
serve({ fetch: app.fetch, port: 3001 })Environment variables (server):
| Variable | Description |
|---|---|
DATABASE_URL |
libsql connection string — file:./dev.db for local, libsql://your-db.turso.io for Turso |
TURSO_AUTH_TOKEN |
(Required when using Turso) Auth token from the Turso dashboard |
ANTHROPIC_API_KEY |
Your Anthropic API key |
ANTHROPIC_MODEL |
Claude model to use. Defaults to claude-sonnet-4-6 |
SECRET_KEY |
Random string used to AES-256 encrypt stored datasource credentials |
AUTH_USER |
(Optional) Enable HTTP Basic Auth for all API routes when paired with AUTH_PASSWORD. |
AUTH_PASSWORD |
(Optional) Enable HTTP Basic Auth for all API routes when paired with AUTH_USER. |
You can customise the route prefix and schema injection behaviour:
mountDashAgent(app, {
prefix: '/dashagent',
// Raise this if your schema is small and you want to skip introspect/summarize
// tool calls. Lower it if your schema is large and you're hitting rate limits.
// Defaults to 2000 tokens (~8000 characters of schema text).
schemaInjectTokenLimit: 5000
})import { ElementRenderer, ChatPanel, useAgentStream } from '@dashagent/react'
// Optional — import the default stylesheet, or write your own
import '@dashagent/react/styles.css'useAgentStream — the core hook. Sends a prompt to the agent and streams events back, updating TanStack Query cache as each query node completes. Loads persisted conversation history on mount.
const { messages, isStreaming, error, send, clearMessages } = useAgentStream(
dashboardId,
conversationId,
datasourceIds
)
send('show me signups by week for the last 6 months')ElementRenderer — renders a single dashboard element (chart, table, metric card). Fetches its own data and re-fetches when variable values or refreshToken change. Supports automatic polling via refreshInterval.
<ElementRenderer
element={element}
variableValues={{ start_date: '2024-01-01', end_date: '2024-12-31' }}
refreshToken={myToken} // change this value to force a re-fetch
refreshInterval={30_000} // auto-refresh every 30 seconds
defaultPageSize={25} // default rows per page for table elements (default: 10)
defaultTickFormat="MMM DD" // app-wide x-axis date format; overridable per-element
defaultDecimalPlaces={2} // app-wide tooltip decimal places; overridable per-element
className="my-card"
colors={['#6366f1', '#f59e0b', '#10b981']}
onEdit={el => setEditing(el)}
onDelete={id => deleteElement(id)}
onChartClick={(data, index) => console.log(data, index)} // called when a data point is clicked on any chart type
/>ChatPanel — drop-in conversation UI with tool call progress indicators and markdown rendering for assistant messages.
<ChatPanel
messages={messages}
isStreaming={isStreaming}
error={error}
onSend={send}
onClearHistory={clearMessages}
/>ElementEditModal — pre-built modal for editing an element's title, data source, and query without invoking the agent. Language is derived automatically from the data source kind.
<ElementEditModal
dashboardId={dashboardId}
element={element}
datasourceIds={dashboard.datasourceIds}
onClose={() => setEditing(null)}
onSaved={() => queryClient.invalidateQueries(...)}
/>@dashagent/react ships a default stylesheet using CSS custom properties. Import it globally to get the default look, or skip it and define the variables yourself.
/* Override any variable on the root or a container */
.my-dashboard {
--dashagent-card-bg: #1e1e2e;
--dashagent-card-border: #313244;
--dashagent-primary: #cba6f7;
--dashagent-title-color: #cdd6f4;
--dashagent-muted-color: #6c7086;
}All component markup uses BEM-style class names (dashagent-element, dashagent-element__title, dashagent-chat, etc.) so you can target any element without specificity fights.
The agent picks the most appropriate visualization for each query result and configures it automatically. You can also reference a specific type in your prompt ("show me a pie chart of...").
| Type | Prompt cues | Best for |
|---|---|---|
bar_chart |
"by", "compare", "breakdown" | Comparing discrete categories |
line_chart |
"over time", "trend", "by month/week/day" | Continuous time-series data |
area_chart |
"cumulative", "stacked over time" | Trends where volume matters |
scatter_chart |
"correlation", "vs", "relationship between" | Two numeric dimensions |
pie_chart |
"share", "proportion", "distribution" | Part-of-whole (best with ≤8 slices) |
All chart types accept xAxis, yAxes, optional stacked, and a colorScheme array. When the query returns multiple numeric columns the agent maps each to a separate series.
The agent can also configure axis display options in response to prompts:
| Config field | Where | Example prompt |
|---|---|---|
yAxes[0].domain |
element config | "set the y-axis to 0–5" |
yAxes[0].ticks |
element config | "show whole number ticks" |
yAxes[0].decimalPlaces |
element config | "show 1 decimal place in the tooltip" |
xAxis.tickFormat |
element config | "format the dates as YYYY-MM-DD" |
tickFormat supports tokens: YYYY, MM, DD, MMM. These fields are also configurable app-wide via defaultTickFormat and defaultDecimalPlaces props on ElementRenderer, with per-element config taking precedence.
table — renders all columns from the query result in a sortable, paginated table. Useful for "list all...", "show me the top N...", or any result where the raw rows are the insight.
Column headers, widths, and sort behaviour are configurable via TableColumnConfig. Page size defaults to 10 and can be overridden globally via defaultPageSize on ElementRenderer, or per-element via config.table.pageSize.
metric_card — a single large number with a label. Supports three formats:
| Format | Example |
|---|---|
number |
12,345 |
currency |
$12,345.00 |
percent |
87.3% |
The agent picks the value key and display label automatically from the query result.
raw_json — shows the query result as formatted JSON. Useful for debugging or when the structure of the data is the point.
Variables let users filter a dashboard without re-running the full agent. Define them when creating a dashboard; the agent references them in queries as {{key}}.
| Type | UI control | Example |
|---|---|---|
date |
Date picker | {{as_of_date}} |
string |
Text input | {{search_term}} |
number |
Number input | {{top_n}} |
enum |
Dropdown | {{region}} → us-west, eu, apac |
multi_select |
Multi-select | {{continents}} → used in post_filter or IN clause |
Changing a variable value causes only the affected query nodes to re-run — nodes that don't reference the changed variable use their cached results.
Datasources are registered via the API. The secret field (password or API key) is sent separately from the connection config so it can be encrypted before storage — it is never returned in API responses.
curl -X POST http://localhost:3001/api/datasources \
-H "Content-Type: application/json" \
-d '{
"name": "Production DB",
"config": {
"kind": "postgres",
"host": "db.example.com",
"port": 5432,
"database": "myapp",
"user": "readonly",
"ssl": true
},
"secret": "my-db-password"
}'For Supabase, set "kind": "supabase" and optionally include supabaseUrl and supabaseAnonKey in the config.
curl -X POST http://localhost:3001/api/datasources/<id>/test
# { "ok": true }curl -X POST http://localhost:3001/api/datasources \
-H "Content-Type: application/json" \
-d '{
"name": "Analytics DB",
"config": {
"kind": "duckdb",
"filePath": "/absolute/path/to/your.duckdb"
}
}'DuckDB support requires the optional duckdb peer dependency:
npm install duckdb| Kind | Status |
|---|---|
| PostgreSQL / Supabase | Supported |
| GraphQL | Supported |
| OpenAPI | Supported |
| DuckDB | Supported (optional peer: npm install duckdb) |
Credentials are encrypted at rest using AES-256 with the SECRET_KEY env var.
All routes are mounted under /api by default.
| Method | Path | Description |
|---|---|---|
GET |
/api/datasources |
List all datasources |
POST |
/api/datasources |
Add a datasource |
GET |
/api/datasources/:id |
Get a datasource |
PATCH |
/api/datasources/:id |
Update name / config / secret |
DELETE |
/api/datasources/:id |
Remove a datasource |
POST |
/api/datasources/:id/test |
Test connectivity |
GET |
/api/schema/:datasourceId |
Introspect schema (cached 1 hour) |
GET |
/api/schema/:datasourceId?refresh=true |
Force re-introspect, bypass cache |
| Method | Path | Description |
|---|---|---|
GET |
/api/dashboards |
List all dashboards |
POST |
/api/dashboards |
Create a dashboard |
GET |
/api/dashboards/:id |
Get dashboard with elements |
PATCH |
/api/dashboards/:id |
Update name / description / datasources |
DELETE |
/api/dashboards/:id |
Delete dashboard and all its data |
PATCH |
/api/dashboards/:id/elements/:elementId |
Update element title or viz config |
DELETE |
/api/dashboards/:id/elements/:elementId |
Delete element (prunes plan node too) |
GET |
/api/dashboards/:id/plan |
Get the active query plan |
PATCH |
/api/dashboards/:id/plan/nodes/:nodeId |
Update a plan node's query / datasource |
GET |
/api/dashboards/:id/history |
Get conversation history |
DELETE |
/api/dashboards/:id/history |
Clear conversation history |
| Method | Path | Description |
|---|---|---|
POST |
/api/agent/run |
Run the agent (SSE stream) |
GET |
/api/agent/execute |
Re-execute a single query node |
packages/
shared/ @dashagent/shared — types and Zod validators
server/ @dashagent/server — Hono route handler + agent + adapters
react/ @dashagent/react — React components and hooks
Built with Turborepo and pnpm workspaces.
- Agent — Anthropic SDK (Claude), streaming via SSE
- Server — Hono + @hono/node-server
- Database — Drizzle ORM + @libsql/client (SQLite / Turso)
- React — TanStack Query, TanStack Router, TanStack Table, Recharts
Issues identified during review, grouped by priority. These should be resolved before a production or npm release.
-
SQL injection via variable substitution — Variable values are spliced into query strings with
replaceAll('{{key}}', value)rather than true parameterized queries. Single quotes are escaped for multi-select arrays but this is still string concatenation. Needs parameterized query support added to each adapter (postgres.ts,graphql.ts,openapi.ts). -
Authentication is not enabled by default — All API routes are protected only when
mountDashAgentis configured with Basic Auth (via theauthoption orAUTH_USER/AUTH_PASSWORDenv vars).
-
ElementRendererbaseUrl/prefix — The component now respects thebaseUrlprop and works with non-/apiroute prefixes. -
ElementConfignot validated at the route layer —PATCH /dashboards/:id/elements/:elementIdacceptsconfig: z.record(z.unknown()). A malformed config is stored without error and only fails at render time. A Zod schema for theElementConfigunion should be added to@dashagent/sharedand enforced at the route. -
Full conversation history loaded on every agent request —
loadConversationHistoryreconstructs the entire history as context on each call. Long sessions balloon token usage and will eventually hit context limits. Needs a windowing strategy (e.g. keep the last N turns, with a summarized prefix for older turns).
-
_nodeMetain plan JSON is unvalidated —generate-query-plan.tsstores{ vizType, elementTitle }as a side-channel in the plan row. If Claude passes an invalidvizType, it is stored silently andrender_elementfalls back toraw_jsonwith no error surfaced to the user. -
agentTurnshas no foreign key to dashboards — Linked only byconversationId. If a dashboard is deleted outside the normal route (e.g. direct DB operation), agent turns are orphaned. Currently handled manually in the delete route. -
Prompt injection via datasource/element names — Datasource names and element titles are included verbatim in the agent system prompt. A crafted name (
"Ignore previous instructions...") would be injected into the LLM context. Low risk while these fields are admin-controlled, but should be sanitized before any user-submitted naming is added. -
ChatPanelcollapse has competing width controls — The.dashagent-chat--collapsedCSS class setswidth: 40pxbut the parent wrapper controls the width. The component should not set its own width; the parent container should own layout entirely. -
execute-mergejoin silently drops duplicate right-side keys — The join implementation uses a simpleMapkeyed onrightKey. If the right-side dataset has duplicate key values, later rows overwrite earlier ones. Behaviour should be documented or the implementation changed to a multi-map.
- Multi-provider AI support — abstract the agent loop behind an
IAgentProviderinterface to support OpenAI, Google Gemini, and others alongside Anthropic. The Vercel AI SDK is the likely vehicle — it normalizes streaming and tool calling across providers and maps closely to the existing event shape. - External refresh hook — a
useAgentStreamorElementRendererprop that accepts a signal/observable to trigger re-fetch from outside the component (e.g. a global "refresh all" button or WebSocket push event). - Agent evaluation tests — golden-path integration tests that run real
Claude calls against a fixture datasource and assert on output structure
rather than exact content: does the agent produce a valid
QueryPlan(Zod passes)? Doesrender_elementproduce a validElementConfig? Does an amendment preserve existing nodes? Does schema context injection prevent column hallucination? These tests are expensive (real API calls) so they should run nightly or on-demand, not in CI. The existing Zod validators are the assertion layer; only a test harness needs to be added. - LLM observability — integrate an observability tool (e.g. Langfuse) to
trace individual agent runs end-to-end: per-tool token usage, latency,
error rates, and cost breakdown. The Anthropic SDK already returns
usageonfinalMessage; surface this in SSEdoneevents and/or log to an observability backend. This is the ground-truth complement to the cost estimates in this README.
MIT