Documentation

Node SDK reference

Full @querypanel/node-sdk surface: schema sync, sessions, chart storage, modifyChart, Deno, and error handling.

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

Start with ask(), mint a JWT, or tenant isolation. This page is the full Node SDK reference.

Installation

npm install @querypanel/node-sdk
# or
bun add @querypanel/node-sdk

Runtime: Node.js 18+, Deno, or Bun. The SDK uses the Web Crypto API for JWT signing and the native fetch API.

Quickstart

attach databases, then ask()
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.
scripts/sync-schema.ts (run once, or after schema changes)
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.

systemPrompt from client config
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

fixed lookback, excluded statuses
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.

supportedChartTypes
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

Deno (e.g. Supabase Edge)
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" });
Build from source (monorepo)
cd node-sdk
bun install
bun run build

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