Architecture

The graph orchestrator

SelectStar's orchestrator is a graph with conditional edges and shared state, not a flat ReAct loop. This is the most common question we get — "wait, no Python, no LangGraph?" Here's the honest, detailed answer.

SelectStar orchestrator graph — Router routes to Schema, SQL (Zen-gated), then EDA/Viz/ML in parallel, then Synthesis
The orchestrator graph — shared AgentState flows through conditional edges. EDA, Viz, and ML run in parallel after a SELECT (fork-join).

Every concept has a TypeScript-native equivalent

The original spec called for Python 3.11 + FastAPI + LangGraph + SQLAlchemy + pandas/scikit-learn. That's an excellent stack. But this project runs in a Next.js 16 + TypeScript environment, so we adapted every concept to its TypeScript-native equivalent — without losing any of the architectural ideas.

TypeScript implementationWhy it's equivalent
Next.js API Routes (src/app/api/*)Async server endpoints with streaming. Route Handlers return Response objects, so we stream SSE identically to FastAPI.
Custom TypeScript orchestrator (src/lib/agents/orchestrator.ts)LangGraph is 'a graph with shared state + conditional edges.' We model the exact same thing with a runTurn() function that branches on the router's output.
DbConnection interface + SqliteConnection / PostgresConnectionSame abstraction as SQLAlchemy: one interface, multiple drivers. Adding MySQL = implement one class.
z-ai-web-dev-sdk via one shared llm.ts wrapperThe SDK is OpenAI-compatible. Swapping providers = change one client. Every agent calls complete() / completeJson() / completeStream().
DataFrame type + frame-cache.tsStore result sets server-side keyed by id, pass only the id + a small preview through LLM context — never serialize a large dataframe into a prompt.
Pure-TS implementations in src/lib/agents/ml.tsOrdinary least squares via normal equations, k-means with k-means++ init, linear-trend forecasting. No Python runtime needed.
react-vega VegaEmbedThe viz agent emits declarative Vega-Lite JSON specs the frontend renders as SVG. No executable plotting code ever runs.
text/event-stream responseThe /api/chat route returns a ReadableStream of SSE events, identical to FastAPI's StreamingResponse.

The orchestrator is a graph, not a loop

LangGraph's value is modeling the flow as a graph with conditional edges and shared state, not a flat ReAct "think → call tool → repeat" loop. We replicate that exactly. Shared state flows through this graph as a single typed AgentStateobject — the conversation history, cached schema snapshot, the router's decision, the most recent query result (by id), the canvas objects produced this turn, any pending write awaiting confirmation, and the final reply.

The orchestrator (runTurn) implements the conditional edges: the Router classifies intent first. If it routes to Schema, that node answers from the cached snapshot. If it routes to SQL, that node generates and executes a single query — and if Zen mode is on and the statement is a write, it stops the graph and awaits user confirmation (the write is registered as pending, never auto-executed). After a successful SELECT, the EDA / Viz / ML nodes run in parallel. Finally, Synthesis — the only node that writes user-facing prose — streams the reply token-by-token.

The six agents

AgentJobToolsLLM calls
Router
Classifies intent, picks agentscompleteJson1 fast call
Schema
Answers from cached snapshotSchema text lookup0–1 calls
SQL
Writes & executes query; gates writesDbConnection, frame-cache1 call (+1 retry)
EDA
Profiles dataframe: stats, nulls, correlationsframe-cache get1 call
Viz
Emits Vega-Lite JSON specframe-cache get1 call + fallback
ML
OLS / k-means / forecast (pure TS)frame-cache, pure-TS math1 call
Synthesis
Streams the final replyagent summaries + canvas1 streaming call

Shared AgentState

Every agent reads from and writes to a single typed state object. This is the LangGraph "shared state" pattern, in TypeScript. The state includes the conversation history, the cached schema snapshot, the router's decision, the most recent query result (by id, never the full rows), the canvas objects produced this turn, any pending write awaiting confirmation, and the final reply.

src/lib/types.ts (excerpt)
interface AgentState {
  // Shared context
  sessionId: string;
  history: Message[];
  schema: SchemaSnapshot;          // cached at connection time

  // Per-turn state
  routerDecision: RouterDecision;  // intent + which agents to run
  queryResult?: { frameId: string; rowCount: number; ms: number };
  canvasObjects: CanvasObject[];   // tables, charts, SQL, ML, etc.

  // Zen mode — pending write
  pendingWrite?: { pendingId: string; sql: string; impact: string };

  // Final reply (streamed by Synthesis)
  reply: string;
}

// CanvasObject is a discriminated union — typed artifacts
type CanvasObject =
  | { kind: "table"; frameId: string; columns: Column[]; rows: Row[] }
  | { kind: "chart"; spec: VegaLiteSpec }
  | { kind: "sql"; sql: string; rowCount?: number; ms?: number }
  | { kind: "eda"; profile: StatisticalProfile }
  | { kind: "model"; metrics: ModelMetrics; predictions: Row[] }
  | { kind: "pending-write"; pendingId: string; sql: string; impact: string }
  | { kind: "error"; message: string };

Why a graph beats a loop for databases

Most AI agents run in a ReAct loop (Reasoning + Acting). The LLM is given a list of tools and left to repeatedly output Thought → Tool Call → Observation until it decides it is finished. While flexible, this approach is notoriously unreliable for database and BI operations.

ReAct loop problems

  • • LLM gets confused by a database error → queries repeatedly, hallucinates, or loops
  • • Hard to guarantee no destructive DROP/DELETE, even with strict prompts
  • • Sequential execution — EDA, Viz, ML run one after another
  • • Full history passed every step — high token cost

Graph orchestrator

  • • Deterministic edges — Router classifies intent up front
  • • Write safety hard-coded at the graph edge (Zen mode)
  • • Fork-join: EDA, Viz, ML run in parallel after a SELECT
  • • Per-agent scoped context — small focused prompts

Extending the graph

Because the graph is implemented in standard TypeScript, you can add new analytical nodes (e.g. PDF exporter, anomaly detector) by defining a new node and updating the conditional routing logic in runTurn().

adding a new agent
// 1. Create src/lib/agents/your-agent.ts
export async function runYourAgent(state: AgentState): Promise<Partial<AgentState>> {
  // your logic here
  return {
    canvasObjects: [...state.canvasObjects, yourArtifact],
  };
}

// 2. Add the agent name to AgentName in src/lib/types.ts
type AgentName = "router" | "schema" | "sql" | "eda" | "viz" | "ml"
               | "synthesis" | "your-agent";  // ← add here

// 3. Add a routing rule in src/lib/agents/router.ts' system prompt
//    so the Router knows when to pick your agent

// 4. Add a node in src/lib/agents/orchestrator.ts' runTurn()
if (decision.agents.includes("your-agent")) {
  const result = await runYourAgent(state);
  Object.assign(state, result);
}

// 5. (Optional) Add a canvas-object type + renderer for structured output

Next steps

Read the API reference to see how the orchestrator is exposed over HTTP, or browse every agent in the features page.