A TypeScript-first client for the QueryPanel Bun/Hono 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 every public route under src/routes/ (query, ingest, charts, active charts, and knowledge base).
bun add @querypanel/sdk
# or
npm install @querypanel/sdkRuntime: Node.js 18+, Deno, or Bun. The SDK uses Web Crypto API for JWT signing and the native
fetchAPI, making it compatible with modern JavaScript runtimes.
import { QueryPanelSdkAPI } from "@querypanel/sdk";
import { Pool } from "pg";
const qp = new QueryPanelSdkAPI(
process.env.QUERYPANEL_URL!,
process.env.PRIVATE_KEY!,
process.env.ORGANIZATION_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();
}
};
// Attach PostgreSQL database using the SDK's PostgresAdapter
// The SDK will automatically handle tenant isolation when tenantFieldName is provided
qp.attachPostgres(
"pg_demo", // a uniq identifier for QueryPanel
createPostgresClientFn(),
{
database: "pg_demo", // database name
description: "PostgreSQL demo database", // some description that QueryPanel can use
tenantFieldName: "tenant_id", // SDK will automatically filter by tenant_id
enforceTenantIsolation: true, // Ensures all queries include tenant_id filter
allowedTables: ["orders"], // Only sync 'orders' table - 'users' will be excluded
});
qp.attachClickhouse(
"clicks", // uniq identifier for QueryPanel
(params) => clickhouse.query(params),
{
database: "analytics", // database name
tenantFieldName: "customer_id", // SDK will automatically filter by tenant_id
tenantFieldType: "String", // SDK will use it in the clickhouse query as {customer_id::String}
},
);
// Syncs schema. Skips embedding if schema hasn't changed (no drift).
// Pass { forceReindex: true } to force re-embedding.
await qp.syncSchema("analytics", { tenantId: "tenant_123" });
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);The SDK allows you to save generated charts to the QueryPanel system.
Privacy Note: QueryPanel only stores the chart definition (SQL query, parameters, and Vega-Lite spec). We never store the actual result data rows. The data is fetched live from your database whenever the chart is rendered or refreshed.
// 1. Ask a question to generate a chart
const response = await qp.ask("Show revenue by country", {
tenantId: "tenant_123",
database: "analytics",
});
if (response.chart.vegaLiteSpec) {
// 2. Save the chart (only stores SQL + metadata, no data)
const savedChart = await qp.createChart({
title: "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" // Optional: associate with a user
});
console.log(`Chart saved with ID: ${savedChart.id}`);
}
// 3. List saved charts (History)
const charts = await qp.listCharts({ tenantId: "tenant_123" });The modifyChart() method allows you to edit SQL and/or visualization settings, then re-execute and regenerate charts. It works with both fresh ask() responses and saved charts.
Modify chart type, axes, or series without regenerating SQL:
// Start with an ask() response
const response = await qp.ask("revenue by country", {
tenantId: "tenant_123",
database: "analytics",
});
// Change to a bar chart with specific axis configuration
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" });
console.log(modified.chart); // New chart spec with bar visualization
console.log(modified.modified.vizChanged); // true
console.log(modified.modified.sqlChanged); // falseThese modifications trigger SQL regeneration:
// Change from daily to monthly aggregation
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" });
console.log(monthly.sql); // New SQL with monthly GROUP BY
console.log(monthly.modified.sqlChanged); // trueProvide custom SQL that will be executed directly:
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' AND created_at > '2024-01-01'
GROUP BY country
ORDER BY total_revenue DESC
LIMIT 10
`,
},
}, { tenantId: "tenant_123" });
// Optionally save the modified chart
if (customized.chart.vegaLiteSpec) {
await qp.createChart({
title: "Top 10 Countries by Revenue (Completed Orders)",
sql: customized.sql,
sql_params: customized.params,
vega_lite_spec: customized.chart.vegaLiteSpec,
target_db: customized.target_db,
}, { tenantId: "tenant_123" });
}Apply both types of modifications in a single call:
const combined = await qp.modifyChart({
sql: response.sql,
question: "revenue over time",
database: "analytics",
sqlModifications: {
timeGranularity: "week",
additionalInstructions: "exclude refunded orders",
},
vizModifications: {
chartType: "area",
stacking: "stacked",
},
}, { tenantId: "tenant_123" });Load a saved chart and modify it:
// Load a saved chart
const savedChart = await qp.getChart("chart_id_123", {
tenantId: "tenant_123",
});
// Modify it
const modified = await qp.modifyChart({
sql: savedChart.sql,
question: "original question", // Store this when saving charts
database: savedChart.target_db ?? "analytics",
params: savedChart.sql_params as Record<string, unknown>,
vizModifications: {
chartType: "line",
},
}, { tenantId: "tenant_123" });While createChart and listCharts manage your history of saved queries, "Active Charts" are designed for building dashboards. You can "pin" a saved chart to a dashboard, control its order, and fetch it with live data in a single call.
// 1. Pin a saved chart to the dashboard
const activeChart = await qp.createActiveChart({
chart_id: "saved_chart_id_from_history",
order: 1, // Optional: for sorting in UI
meta: { width: "full", variant: "dark" } // Optional: UI layout hints
}, {
tenantId: "tenant_123"
});
// 2. Load the dashboard with live data
// Passing { withData: true } executes the SQL for each chart immediately
const dashboard = await qp.listActiveCharts({
tenantId: "tenant_123",
withData: true
});
dashboard.data.forEach(item => {
console.log(`Chart: ${item.chart?.title}`);
console.log(`Data points: ${item.chart?.vega_lite_spec.data.values.length}`);
});The SDK is fully compatible with Deno (including Supabase Edge Functions) thanks to its use of Web Crypto API for JWT signing. No additional configuration needed:
import { QueryPanelSdkAPI } from "https://esm.sh/@querypanel/sdk";
const qp = new QueryPanelSdkAPI(
Deno.env.get("QUERYPANEL_URL")!,
Deno.env.get("PRIVATE_KEY")!,
Deno.env.get("ORGANIZATION_ID")!,
);
// Use the SDK as normal - JWT signing works automatically
const response = await qp.ask("Show top products", {
tenantId: "tenant_123",
});cd node-sdk
bun install
bun run buildThis runs tsup which emits dual ESM/CJS bundles plus type declarations to dist/.
Every request is signed with RS256 using the private key you pass to the constructor. The payload always includes organizationId and tenantId; userId and scopes are added when provided per call. If you still need service tokens or custom middleware, pass additional headers via the constructor.
- HTTP errors propagate as thrown
Errorinstances that includestatus(anddetailswhen available). syncSchemaautomatically skips embedding if the schema hasn't changed (drift detection). UsesyncSchema(..., { forceReindex: true })to force updates.ask()raises immediately for guardrail/moderation errors because/queryresponds with 4xx/5xx.
When SQL execution fails (e.g., invalid column name, syntax error), the SDK can automatically retry with a repaired query:
const response = await qp.ask("Show revenue by country", {
tenantId: "tenant_123",
maxRetry: 3, // Automatically retry up to 3 times on execution error
});
console.log(`Query succeeded after ${response.attempts} attempt(s)`);
console.table(response.rows);The SDK will:
- Execute the generated SQL
- If execution fails, send the error back to the server with the failed SQL
- Get a repaired SQL query from the server
- Execute the repaired query
- Repeat up to
maxRetrytimes
Without maxRetry, execution errors throw immediately (default behavior).
Open an issue or extend node-sdk/src/index.ts—every route lives in one file. Pull requests are welcome for additional adapters, richer param coercion, or convenience helpers around charts/annotations.