A graph orchestrator, not a chatbot with tools
Six specialized agents coordinated by a graph. Each one has a single job, a small toolset, and a focused system prompt. Below, every agent is broken down with its job, its tools, and the code that runs it.
Router
Classifies the user's message into needed capabilities
The Router is the entry point of every turn. It reads the user's message and decides which downstream agents need to run — Schema, SQL, EDA, Viz, ML, or some combination. It's a lightweight JSON-mode call optimized for latency, not prose. By classifying intent up front, the Router prevents the unbounded reasoning loops that plague ReAct-style agents.
- Single fast JSON-mode LLM call — typically <400ms
- Outputs a structured intent: schema lookup, SQL query, EDA, viz, ML, or synthesis
- Determines which downstream agents run, eliminating agent wander
- Re-runs on every turn — context is always fresh
// src/lib/agents/router.ts
const decision = await llm.completeJson({
system: ROUTER_SYSTEM_PROMPT,
user: message,
schema: {
intent: ["schema", "sql", "eda", "viz", "ml", "synthesis"],
agents: { type: "array", items: ["sql", "eda", "viz", "ml"] },
reason: "string",
},
});
// → { intent: "sql", agents: ["sql", "eda", "viz"], reason: "..." }Without a Router, the LLM has to decide what tools to call inside its reasoning loop — that's where ReAct agents wander, hallucinate, and burn tokens.
The Router makes that decision up front in a single fast JSON call. Downstream agents only run if they're needed.
Schema
Answers structural questions from the cached snapshot
The Schema agent answers questions about the database structure — what tables exist, what columns they have, primary and foreign keys, approximate row counts. It works from a cached schema snapshot taken at connection time, so it's instant. It only calls the LLM to phrase the answer in natural language, never to reason about the schema.
- Reads from a cached snapshot — no live queries for structural questions
- Re-runs introspection on demand via /api/refresh-schema
- Surfaces primary keys, foreign keys, and approximate row counts
- Generates suggested starter questions from the schema
// After /api/connect, the schema snapshot is cached:
{
tables: [
{ name: "orders", columns: [...], pk: "id", rowHint: 3214 },
{ name: "order_items", columns: [...], pk: "id", fk: "orders.id" },
// ...
]
}
// The Schema agent just looks this up — no LLM reasoning needed.Most "what tables exist" questions don't need an LLM at all — they need a cache lookup.
Schema answers from the cached snapshot are instant. The LLM only writes the prose, never reasons about structure.
SQL
Generates & executes a single query; gates writes in Zen mode
The SQL agent is the only agent that touches the live database. It generates a single query from the user's question, executes it, and stores the result in the frame-cache. If Zen mode is on and the statement is a write, it never auto-executes — it registers a pending write with a 10-minute TTL and surfaces it to the user for confirmation. On a syntax error, it gets exactly one regex-fallback retry before surfacing a structured error.
- Only agent permitted to construct or execute SQL — safety by design
- Zen mode gates writes: pending writes never auto-execute
- One retry on failure, then a clean structured error — no infinite loops
- Results cached by id, only the id + preview passed downstream
// src/lib/agents/sql.ts
const sql = await llm.complete({
system: SQL_SYSTEM_PROMPT,
user: `${schemaSnapshot}
Question: ${question}`,
});
if (isWrite(sql) && zenMode) {
// Register as pending — NEVER auto-execute
const pending = await pendingWrites.register({ sql, sessionId, ttl: 600 });
return { kind: "pending-write", pending };
}
const result = await db.query(sql);
const frameId = await frameCache.put(result);
return { kind: "sql", sql, frameId, rowCount: result.rows.length };Only the SQL agent can construct or execute SQL. No other agent has database access — safety by design.
In Zen mode, writes are intercepted at the graph edge, never auto-executed. One retry on failure, then a clean error.
EDA
Profiles a dataframe: stats, nulls, distributions, correlations
The EDA (Exploratory Data Analysis) agent takes a cached dataframe and produces a statistical profile: column types, null percentages, min/max/mean/median, value distributions, and correlations between numeric columns. The math is done in pure TypeScript — no Python, no pandas. The LLM only writes the narrative summary that accompanies the stats.
- Pure-TS statistics: mean, median, std-dev, quartiles, correlations
- Null-percentage bars rendered inline in the canvas
- Distribution histograms for numeric columns
- Runs in parallel with Viz and ML after a SELECT
// src/lib/agents/eda.ts
const frame = await frameCache.get(frameId);
const profile = {
columns: frame.columns.map(col => ({
name: col.name,
type: col.type,
nullPct: nullPercentage(frame, col),
stats: col.type === "number"
? numericStats(frame, col) // mean, median, std, quartiles
: categoricalStats(frame, col), // top values, cardinality
})),
correlations: correlationMatrix(frame),
};
return { kind: "eda", profile };Statistical profiling in pure TypeScript — no pandas, no Python runtime.
The math runs in the Node process. The LLM only writes the narrative that accompanies the stats.
Viz
Decides chart type & emits a Vega-Lite JSON spec
The Viz agent looks at the dataframe and decides the right chart type — bar, line, scatter, pie, heatmap — then emits a declarative Vega-Lite JSON spec. The frontend renders the spec as SVG via react-vega. No executable plotting code ever runs in the browser, which is a deliberate safety choice. If the LLM picks a chart type that doesn't fit the data, a smart fallback rewrites the spec.
- Emits declarative Vega-Lite JSON — never executable plotting code
- Smart fallback rewrites bad specs (e.g. scatter → bar for categorical data)
- Charts export as SVG or PNG directly from the canvas
- Runs in parallel with EDA and ML after a SELECT
// src/lib/agents/viz.ts
const spec = await llm.completeJson({
system: VIZ_SYSTEM_PROMPT,
user: `${frameSummary}
Question: ${question}`,
schema: vegaLiteSchema,
});
// Validate against Vega-Lite schema; fall back if invalid
const validated = validateVegaLite(spec) ?? fallbackSpec(frame);
return { kind: "viz", spec: validated };Charts are declarative Vega-Lite JSON, never executable plotting code. A deliberate safety choice.
If the LLM picks the wrong chart type, a smart fallback rewrites the spec automatically.
ML
Runs OLS regression / k-means / linear-trend forecast
The ML agent picks the right model for the question — ordinary least-squares regression for relationships, k-means (with k-means++ initialization) for clustering, or linear-trend forecasting for time series. All the math is implemented in pure TypeScript: normal equations + Gaussian elimination for OLS, k-means++ for initialization. No Python runtime, no scikit-learn, no extra dependencies.
- OLS regression via normal equations + Gaussian elimination
- k-means clustering with k-means++ initialization
- Linear-trend forecasting with R² and slope metrics
- Pure TypeScript — no Python runtime, no extra dependencies
// src/lib/agents/ml.ts (pure TypeScript, no Python)
const model = await llm.completeJson({
system: ML_SYSTEM_PROMPT,
user: `${frameSummary}
Question: ${question}`,
schema: { model: ["ols", "kmeans", "forecast"], config: "object" },
});
switch (model.model) {
case "ols":
return { kind: "ml", result: olsRegression(frame, model.config) };
case "kmeans":
return { kind: "ml", result: kmeans(frame, model.config.k, { init: "kpp" }) };
case "forecast":
return { kind: "ml", result: linearForecast(frame, model.config.horizon) };
}OLS regression via normal equations. k-means with k-means++ initialization. Linear-trend forecasting with R².
All in pure TypeScript. No scikit-learn, no Python runtime, no extra dependencies.
Synthesis
Writes the final user-facing reply (streamed)
Synthesis is the only agent that writes user-facing prose. It receives the summaries from the other agents that ran this turn plus the list of canvas objects produced, and streams a natural-language reply token-by-token via SSE. Because it's the only agent that writes prose, the rest of the pipeline stays context-isolated — the Viz agent never sees credentials, the SQL agent never sees the full chat history.
- Only agent that writes user-facing prose — strict context isolation
- Streams token-by-token via Server-Sent Events
- Receives only summaries, never raw rows or credentials
- References canvas objects by id, keeping the reply grounded
// src/lib/agents/synthesis.ts
const stream = await llm.completeStream({
system: SYNTHESIS_SYSTEM_PROMPT,
user: `${agentSummaries}
Canvas: ${canvasObjectIds}`,
});
for await (const token of stream) {
yield { type: "token", value: token };
}
yield { type: "reply_done" };The only agent that writes user-facing prose. Strict context isolation.
Receives only summaries from other agents — never raw rows, never credentials. Streams token-by-token via SSE.
Organized capabilities, not a feature dump
The agent pipeline, safety & control, canvas artifacts, and connectivity — the four pillars of SelectStar's architecture.
Agent Pipeline
Six specialized agents — Router, Schema, SQL, EDA, Viz, ML — coordinated by a graph orchestrator. Each agent has one job, a small toolset, and a focused system prompt. The orchestrator decides which agents run, not the user, not the agents.
- Router classifies intent & picks agents
- SQL agent writes & executes queries
- EDA agent profiles & computes stats
- Viz agent emits Vega-Lite specs
- ML agent: regression, k-means, forecast
- Synthesis streams the final reply
Safety & Control
Zen mode is non-negotiable. Read-only by default. Writes are intercepted at the graph edge, never auto-executed. Every write resolution — executed, rolled-back, cancelled, or failed — is audit-logged with the SQL text, timestamp, and row count.
- Read-only by default
- Zen-mode toggle for writes
- Per-write confirmation UI
- Dry-run with rollback
- Full audit log
Canvas Artifacts
Every turn produces structured artifacts in the canvas pane alongside the chat reply. Tables, charts, SQL, statistical profiles, and model results — each rendered as a typed component with export buttons. No copy-pasting from a terminal.
- Query result tables
- Vega-Lite charts (SVG)
- Syntax-highlighted SQL
- Statistical summaries
- Model results with metrics
Connectivity & Classic Mode
Two ways to get data into SelectStar: connect a live database (SQLite, PostgreSQL, MySQL-ready) or upload spreadsheets (CSV, XLSX, ODS). Classic mode parses each file into in-memory SQLite tables — the agents treat them like any database, and you can edit cells directly.
- SQLite via better-sqlite3
- PostgreSQL via pg
- CSV / XLSX / ODS upload
- Any OpenAI-compatible LLM
- Demo DB included
Structured results, not just chat
Every turn produces typed artifacts in the canvas pane alongside the chat reply — tables, charts, SQL, statistical profiles, and model results. Each one exports to a real file format.

Query Result Table
When the SQL agent executes a SELECT, the result set renders as a sortable, scrollable table in the canvas. Export to CSV with one click. The full frame is cached server-side by id — only the id and a small preview travel through the LLM context, so large result sets never bloat the prompt.
- Sortable, scrollable result table with column types
- One-click CSV export
- Server-side frame-cache — large results don't bloat prompts
- Row count + execution time shown inline

Vega-Lite Chart
The Viz agent emits a declarative Vega-Lite JSON spec, and the canvas renders it as SVG via react-vega. Bar, line, scatter, pie, heatmap — the agent picks the right type for the data. Export as SVG or PNG. No executable plotting code ever runs in the browser.
- Declarative Vega-Lite JSON rendered as SVG
- Agent picks chart type based on data shape
- Smart fallback rewrites bad specs automatically
- Export as SVG or PNG from the canvas

ML Model Result
When the ML agent runs — regression, k-means, or forecast — the canvas shows the model metrics (R², slope, cluster centroids) and a predictions table. All math is pure TypeScript: OLS via normal equations, k-means with k-means++ init. No Python runtime required.
- Model metrics: R², slope, cluster sizes, inertia
- Predictions table with cluster assignments
- Pure-TS math — no Python, no scikit-learn
- Re-runs instantly from cached frames

Spreadsheet Grid (Classic Mode)
In Classic Mode, uploaded CSV/XLSX files render as an editable spreadsheet grid. Set cells, add/rename/delete rows and columns — every edit writes through to the in-memory SQLite table underneath. The SQL agent sees your edits live; you can ask a question, edit a cell, and ask again without re-uploading.
- Edit cells, add/rename/delete rows and columns
- Edits write through to in-memory SQLite instantly
- SQL agent sees edits live — no re-upload needed
- Export the edited table back as CSV or XLSX
Want to see the agents run on your database?
Open the live app, type 'demo', and ask your first question in 60 seconds. No install required.