Skip to content

mccullochk/dashagent

Repository files navigation

DashAgent

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.


What it does

  1. Connect a datasource — add a Postgres connection string via the UI or API
  2. Prompt the agent — "show me total revenue by month for the last year"
  3. Watch it work — Claude introspects the schema, plans the queries, executes them, picks chart types, and streams results back as server-sent events
  4. See your dashboard — charts, tables, and metric cards render as each query completes
  5. 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.


Architecture

Control boundaries

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
Loading

Agent run flow

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
Loading

Variable flow

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
Loading

Element lifecycle

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
Loading

Packages

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.

@dashagent/server

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
})

@dashagent/react

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(...)}
/>

Styling

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


Visualization types

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

Charts

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

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

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

raw_json — shows the query result as formatted JSON. Useful for debugging or when the structure of the data is the point.


Dashboard variables

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.


Connecting a datasource

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.

PostgreSQL / Supabase

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.

Test connectivity

curl -X POST http://localhost:3001/api/datasources/<id>/test
# { "ok": true }

DuckDB (local file)

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

Supported datasource kinds

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.


API routes

All routes are mounted under /api by default.

Datasources

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

Dashboards

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

Agent

Method Path Description
POST /api/agent/run Run the agent (SSE stream)
GET /api/agent/execute Re-execute a single query node

Monorepo structure

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.


Tech stack


Known issues and technical debt

Issues identified during review, grouped by priority. These should be resolved before a production or npm release.

Critical

  • 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 mountDashAgent is configured with Basic Auth (via the auth option or AUTH_USER/AUTH_PASSWORD env vars).

Architectural

  • ElementRenderer baseUrl/prefix — The component now respects the baseUrl prop and works with non-/api route prefixes.

  • ElementConfig not validated at the route layerPATCH /dashboards/:id/elements/:elementId accepts config: z.record(z.unknown()). A malformed config is stored without error and only fails at render time. A Zod schema for the ElementConfig union should be added to @dashagent/shared and enforced at the route.

  • Full conversation history loaded on every agent requestloadConversationHistory reconstructs 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).

Low priority

  • _nodeMeta in plan JSON is unvalidatedgenerate-query-plan.ts stores { vizType, elementTitle } as a side-channel in the plan row. If Claude passes an invalid vizType, it is stored silently and render_element falls back to raw_json with no error surfaced to the user.

  • agentTurns has no foreign key to dashboards — Linked only by conversationId. 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.

  • ChatPanel collapse has competing width controls — The .dashagent-chat--collapsed CSS class sets width: 40px but the parent wrapper controls the width. The component should not set its own width; the parent container should own layout entirely.

  • execute-merge join silently drops duplicate right-side keys — The join implementation uses a simple Map keyed on rightKey. 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.


Roadmap

  • Multi-provider AI support — abstract the agent loop behind an IAgentProvider interface 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 useAgentStream or ElementRenderer prop 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)? Does render_element produce a valid ElementConfig? 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 usage on finalMessage; surface this in SSE done events and/or log to an observability backend. This is the ground-truth complement to the cost estimates in this README.

License

MIT

About

AI-powered dashboard builder. Connect a database, type a natural language prompt, and an LLM introspects your schema, writes a query plan, executes it, and renders charts and tables — all in real time.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors