Node SDK
A TypeScript-first client for the QueryPanel API. Its primary function is to generate SQL from natural language, but it also signs JWTs with your service private key, syncs database schemas, enforces tenant isolation, and wraps public routes (query, ingest, charts, active charts, knowledge base).
Package: @querypanel/node-sdk (published name; some older README snippets may show @querypanel/sdk).
Installation
npm install @querypanel/node-sdk
# or
bun add @querypanel/node-sdkRuntime: Node.js 18+, Deno, or Bun. The SDK uses the Web Crypto API for JWT signing and the native fetch API.
Quickstart
import { QueryPanelSdkAPI } from "@querypanel/node-sdk";
import { Pool } from "pg";
const qp = new QueryPanelSdkAPI(
process.env.QUERYPANEL_URL!,
process.env.PRIVATE_KEY!,
process.env.QUERYPANEL_WORKSPACE_ID!,
{ defaultTenantId: process.env.DEFAULT_TENANT_ID },
);
const pool = new Pool({ connectionString: process.env.POSTGRES_URL });
const createPostgresClient = () => async (sql: string, params?: unknown[]) => {
const client = await pool.connect();
try {
const result = await client.query(sql, params);
return {
rows: result.rows,
fields: result.fields.map((field) => ({ name: field.name })),
};
} finally {
client.release();
}
};
qp.attachPostgres(
"pg_demo",
createPostgresClient(),
{
database: "pg_demo",
description: "PostgreSQL demo database",
tenantFieldName: "tenant_id",
enforceTenantIsolation: true,
allowedTables: ["orders"],
},
);
qp.attachClickhouse(
"analytics",
(params) => clickhouse.query(params),
{
database: "analytics",
tenantFieldName: "customer_id",
tenantFieldType: "String",
},
);
const response = await qp.ask("Top countries by revenue", {
tenantId: "tenant_123",
database: "analytics",
});
console.log(response.sql);
console.log(response.params);
console.table(response.rows);
console.log(response.chart.vegaLiteSpec);Sync schema in a separate setup or migration step — not on the request path. See Schema sync.
Schema sync
syncSchema introspects an attached database and uploads table/column metadata so natural-language questions can generate accurate SQL. Treat it like a migration, not part of ask().
- Run it once when you first connect a database.
- Run it again when tables or columns change.
- Do not call it on every request — put it in a setup/deploy script instead.
import { QueryPanelSdkAPI } from "@querypanel/node-sdk";
const qp = new QueryPanelSdkAPI(
process.env.QUERYPANEL_URL!,
process.env.PRIVATE_KEY!,
process.env.QUERYPANEL_WORKSPACE_ID!,
);
qp.attachPostgres("pg_demo", createPostgresClient(), {
database: "pg_demo",
tenantFieldName: "tenant_id",
enforceTenantIsolation: true,
allowedTables: ["orders"],
});
qp.attachClickhouse("analytics", (params) => clickhouse.query(params), {
database: "analytics",
tenantFieldName: "customer_id",
tenantFieldType: "String",
});
await qp.syncSchema("pg_demo", { tenantId: "tenant_123" });
await qp.syncSchema("analytics", { tenantId: "tenant_123" });Unchanged schemas skip re-embedding. Pass forceReindex: true only when you need to rebuild embeddings. Optionally limit the payload with tables: ["orders"].
Custom system prompt
Pass systemPrompt on ask() to inject extra instructions for SQL generation. Use it when a client needs policies that QueryPanel does not know about — retention windows, a max date range, excluded event types, and similar product rules. Your backend should look up that client's config and pass it on every question.
The text is appended to the SQL generator (and reflection) prompts. When it conflicts with generic defaults (for example the usual 30-day trend window), the caller instructions win. It does not replace tenant isolation — still attach with tenantFieldName and pass tenantId. Max length is 8,000 characters.
Per-client retention and max date range
Typical pattern: resolve the authenticated customer, then build a prompt from their retention settings so questions like "all time" still stay inside the allowed window.
function systemPromptForClient(client: {
retentionDays: number;
}) {
return [
`Data retention: only query rows from the last ${client.retentionDays} days.`,
`Never return a date range longer than ${client.retentionDays} days, even if the user asks for all time or a wider window.`,
"Clamp any requested range to that maximum and bind start/end dates as parameters.",
].join(" ");
}
const client = await loadClient(req); // your auth + billing/retention settings
const response = await qp.ask("Revenue by product over all time", {
tenantId: client.tenantId,
database: "analytics",
systemPrompt: systemPromptForClient(client),
});Other per-client instructions
const response = await qp.ask("Show order volume by week", {
tenantId: client.tenantId,
database: "analytics",
systemPrompt: [
"Default time range: last 90 days unless the user names a shorter window.",
"Maximum lookback: 90 days from today.",
"Exclude orders with status refunded or cancelled.",
].join(" "),
});systemPrompt is per ask() call — there is no constructor default. Pass the same string on every question for that client if the policy must always apply. modifyChart() does not take systemPrompt; re-ask if you need the policy applied to a new SQL generation.
Restricting chart types
If your UI only supports a subset of chart kinds, set supportedChartTypes on the SDK constructor or on each ask / modifyChart call.
import { QueryPanelSdkAPI, ALL_VIZ_CHART_TYPES, type ChartType } from "@querypanel/node-sdk";
const allowed: ChartType[] = ["line", "bar", "column", "pie"];
const qp = new QueryPanelSdkAPI(url, privateKey, workspaceId, {
supportedChartTypes: allowed,
});
await qp.ask("Revenue by month", {
tenantId: "t1",
database: "analytics",
supportedChartTypes: allowed,
});Use ALL_VIZ_CHART_TYPES when you need the full list to filter client-side.
Session history & context-aware queries
Link follow-ups (e.g. "filter that to Europe") by reusing querypanelSessionId from the previous response.
const first = await qp.ask("Revenue by country", {
tenantId: "tenant_123",
database: "analytics",
});
const followUp = await qp.ask("Now filter that to Europe", {
tenantId: "tenant_123",
database: "analytics",
querypanelSessionId: first.querypanelSessionId,
});Managing session history
const sessions = await qp.listSessions({
tenantId: "tenant_123",
pagination: { page: 1, limit: 20 },
sortBy: "updated_at",
});
const session = await qp.getSession("session_abc123", {
tenantId: "tenant_123",
includeTurns: true,
});
await qp.updateSession(
"session_abc123",
{ title: "Q4 Revenue Analysis" },
{ tenantId: "tenant_123" },
);
await qp.deleteSession("session_abc123", { tenantId: "tenant_123" });Saving & managing charts
QueryPanel stores chart definition (SQL, parameters, Vega-Lite spec) — not result rows. Data is loaded live from your database when charts render.
const response = await qp.ask("Show revenue by country", {
tenantId: "tenant_123",
database: "analytics",
});
if (response.chart.vegaLiteSpec) {
const savedChart = await qp.createChart(
{
title: "Revenue by Country",
prompt: "Show revenue by country",
sql: response.sql,
sql_params: response.params,
vega_lite_spec: response.chart.vegaLiteSpec,
query_id: response.queryId,
target_db: response.target_db,
},
{ tenantId: "tenant_123", userId: "user_456" },
);
}
const charts = await qp.listCharts({ tenantId: "tenant_123" });List all & bulk fetch charts
listAllCharts() returns { data, pagination } (same as listCharts()). Older code expecting a plain array should migrate to result.data.
const { data, pagination } = await qp.listAllCharts({
tenantId: "tenant_123",
pagination: { page: 1, limit: 50 },
includeData: true,
});
const { data: bulk, missingIds } = await qp.getChartsByIds(
["550e8400-e29b-41d4-a716-446655440000"],
{ tenantId: "tenant_123", includeData: true },
);Modifying charts
modifyChart() edits SQL and/or visualization, re-executes, and regenerates charts. Works with fresh ask() results or saved charts. Combine vizModifications, sqlModifications, and optional querypanelSessionId for follow-up context.
Visualization only
const modified = await qp.modifyChart(
{
sql: response.sql,
question: "revenue by country",
database: "analytics",
vizModifications: {
chartType: "bar",
xAxis: { field: "country", label: "Country" },
yAxis: { field: "revenue", label: "Total Revenue", aggregate: "sum" },
},
},
{ tenantId: "tenant_123" },
);Time granularity & SQL changes
const monthly = await qp.modifyChart(
{
sql: response.sql,
question: "revenue over time",
database: "analytics",
sqlModifications: {
timeGranularity: "month",
dateRange: { from: "2024-01-01", to: "2024-12-31" },
},
},
{ tenantId: "tenant_123", querypanelSessionId: response.querypanelSessionId },
);Custom SQL & saved charts
const customized = await qp.modifyChart(
{
sql: response.sql,
question: "revenue by country",
database: "analytics",
sqlModifications: {
customSql: `SELECT country, SUM(revenue) as total_revenue
FROM orders WHERE status = 'completed' GROUP BY country`,
},
},
{ tenantId: "tenant_123" },
);
const savedChart = await qp.getChart("chart_id_123", { tenantId: "tenant_123" });
const fromSaved = await qp.modifyChart(
{
sql: savedChart.sql,
question: savedChart.prompt ?? "original question",
database: savedChart.target_db ?? "analytics",
params: savedChart.sql_params as Record<string, unknown>,
vizModifications: { chartType: "line" },
},
{ tenantId: "tenant_123" },
);Active charts & dashboards
Pin saved charts to a dashboard, order tiles, and load live data. listAllActiveCharts() calls GET /active-charts/all when you need every pin without walking pages. getActiveChartsByIds() bulk-fetches by active-chart row IDs (up to 100 UUIDs).
const activeChart = await qp.createActiveChart(
{
chart_id: "saved_chart_id_from_history",
order: 1,
meta: { width: "full", variant: "dark" },
},
{ tenantId: "tenant_123" },
);
const dashboard = await qp.listActiveCharts({
tenantId: "tenant_123",
withData: true,
});
const all = await qp.listAllActiveCharts({
tenantId: "tenant_123",
withData: true,
});
const { data, missingIds } = await qp.getActiveChartsByIds(
["550e8400-e29b-41d4-a716-446655440000"],
{ tenantId: "tenant_123", withData: true },
);Deno support & building locally
import { QueryPanelSdkAPI } from "https://esm.sh/@querypanel/node-sdk";
const qp = new QueryPanelSdkAPI(
Deno.env.get("QUERYPANEL_URL")!,
Deno.env.get("PRIVATE_KEY")!,
Deno.env.get("QUERYPANEL_WORKSPACE_ID")!,
);
const response = await qp.ask("Show top products", { tenantId: "tenant_123" });cd node-sdk
bun install
bun run buildEmits dual ESM/CJS + types to dist/ via tsup.
Authentication, errors & SQL retry
Requests are signed with RS256 using your private key. The payload includes organizationId and tenantId; add userId / scopes per call when needed. Pass extra headers through the constructor for custom middleware.
HTTP errors surface as Error with status and optional details. syncSchema skips embedding when unchanged unless forceReindex: true.
Automatic SQL repair
const response = await qp.ask("Show revenue by country", {
tenantId: "tenant_123",
maxRetry: 3,
});
console.log(`Query succeeded after ${response.attempts} attempt(s)`);Without maxRetry, execution errors throw immediately.
React SDK
Ship customer-facing analytics in React: the main product story is the embedded dashboard — a single component that loads a published workspace with AI editing, charts, and optional tenant customization. Your workspace private key stays on the server; you mint a short-lived tenant JWT there, and the embed calls the QueryPanel API at apiBaseUrl with Authorization: Bearer — no secrets in the browser.
Package: @querypanel/react-sdk
Try it: the interactive embedded dashboard demo walks through JWT setup and a live QuerypanelEmbedded run — same pattern you will copy into your app.
Embedded dashboard — one component
QuerypanelEmbedded is the fastest path to a full analytics experience: pass a dashboard id, the QueryPanel API base URL (the same host you use with the Node SDK, e.g. your cloud region or self-hosted deployment), and a JWT you generate for the end user. The component issues authenticated requests to that API. Optional allowCustomization enables copy-on-write forks; darkMode, colorPreset, theme, and branding cover look-and-feel and white-label copy.
On the server, use the Node SDK to sign a tenant-scoped token (e.g. createJwt with tenantId, userId, and scopes). Pass the resulting JWT to the browser; the embed sends it to QueryPanel — not a copy of your workspace private key.
// Your API route (Node) — e.g. matches /demo/embed “Run embed” flow
import { QueryPanelSdkAPI } from "@querypanel/node-sdk";
const qp = new QueryPanelSdkAPI(apiBaseUrl, privateKeyPem, organizationId);
const jwt = await qp.createJwt({
tenantId: "tenant_abc",
userId: "user_123",
scopes: ["dashboards:read", "charts:read"],
});
// In your customer-facing React app:
import { QuerypanelEmbedded } from "@querypanel/react-sdk";
export function CustomerAnalytics() {
return (
<QuerypanelEmbedded
dashboardId="YOUR_DASHBOARD_UUID"
apiBaseUrl="https://api.querypanel.io" // same QueryPanel API base URL as the Node SDK
jwt={jwt}
allowCustomization
darkMode
/>
);
}The live demo at /demo/embed pairs this with a session generator and a full-screen preview so you can validate the end-to-end flow in minutes.
Callbacks: onLoad, onError, onCustomize (when a customer forks). See the TypeScript QuerypanelEmbeddedProps type in the package for the full list.
Installation
npm install @querypanel/react-sdk
# or
pnpm add @querypanel/react-sdk
# or
yarn add @querypanel/react-sdkImport the distributed CSS from the package when your bundler requires an explicit stylesheet entry (see package exports and README).
Custom NL-to-chart UI (provider)
If you are building a bespoke experience instead of the full embed, use QueryPanelProvider and wire your own /api/ask routes. This path gives you QueryInput, QueryResult, and loading/empty/error states.
import {
QueryPanelProvider,
QueryInput,
QueryResult,
LoadingState,
EmptyState,
ErrorState,
useQueryPanel,
} from "@querypanel/react-sdk";
function App() {
return (
<QueryPanelProvider
config={{
askEndpoint: "/api/demo/ask",
modifyEndpoint: "/api/demo/modify",
colorPreset: "default",
}}
>
<Dashboard />
</QueryPanelProvider>
);
}
function Dashboard() {
const { query, result, isLoading, error, ask, modify, colorPreset } = useQueryPanel();
// ... QueryInput, QueryResult, state components
}Composable building blocks
Mix and match lower-level pieces — VegaChart, DataTable, ChartControls — for screens that you fully control. Same theming entry points as the provider.
import { VegaChart, DataTable, ChartControls } from "@querypanel/react-sdk";
import { getColorsByPreset } from "@querypanel/react-sdk/themes";
const colors = getColorsByPreset("ocean");
// <ChartControls ... /> <VegaChart spec={...} colors={colors} /> <DataTable ... />Theming
Presets: default, sunset, emerald, ocean.
import { getColorsByPreset, createTheme } from "@querypanel/react-sdk/themes";
const customTheme = createTheme({
colors: { primary: "#FF6B6B", secondary: "#4ECDC4" },
borderRadius: "1rem",
fontFamily: "Inter, sans-serif",
});White-labeling
Components accept a colors prop; embeds support branding for toolbar and AI copy.
<VegaChart
spec={spec}
colors={{
primary: "#YOUR_BRAND_COLOR",
text: "#ffffff",
muted: "#888888",
}}
/>Types
interface QueryResult {
success: boolean;
sql?: string;
rows?: Array<Record<string, unknown>>;
fields?: string[];
chart?: {
vegaLiteSpec?: Record<string, unknown>;
};
}More components & live examples
Beyond the embed and provider stack, the React SDK includes dashboard editors, AI chart flows, and many composable details. This page is a high-level map; for every prop, story, and edge case, use the interactive catalog.
Open Storybook — all components, controls, and docsStart with the QuerypanelEmbedded and QueryPanelProvider stories, then explore inputs, states, and layout pieces as you need them.
License
MIT (per package README).