Writing board SQL

The dialect, the tables you can reach, filter tokens, and what each rejection means.
View as Markdown

Every widget is one SQL query. Sarvam’s analytics store is ClickHouse — column-oriented and tuned for aggregation over large call datasets, so some functions and conventions differ from PostgreSQL or MySQL.

This page covers what the API enforces. For dialect patterns and worked query shapes, see SQL best practices.

Start from the schema

Call Get query schema before writing anything. It returns the tables available to this workspace with their columns and ClickHouse types:

{
"tables": [
{
"name": "EngagementFactsBoardsView",
"columns": [
{ "name": "interaction_id", "type": "String" },
{ "name": "effective_datetime", "type": "DateTime" },
{ "name": "v2v_connectivity_status", "type": "LowCardinality(String)" },
{ "name": "language_name", "type": "LowCardinality(String)" },
{ "name": "audio_duration", "type": "Nullable(Float32)" }
]
}
]
}

Table names are unqualified — use them exactly as returned, with no database prefix. Referencing anything outside this catalog fails with FORBIDDEN_TABLE.

Do not write org_id or workspace_id predicates. Tenant scoping is applied server-side on every query and cannot be bypassed or opted out of. Adding your own scoping clause is at best redundant; results are additionally verified before they are returned, and a response that would cross tenants is refused with TENANT_ISOLATION_BREACH.

Read-only, single statement

Widget SQL must be exactly one SELECT. CTEs, subqueries, joins, and unions are all fine as long as they only read.

These are rejected at save time, not at run time:

RejectedWhy
Anything other than SELECTThe surface is read-only
Two statements separated by ;One statement per widget
SETTINGSWould override the tenancy, read-only, and timeout settings applied per query
FORMAT, INTO OUTFILEWould control the wire format or write to disk
LIMIT BYNot supported in widget SQL
LIMIT with OFFSETUse row_limit instead
LIMIT that is not a positive integer literalMust be a constant

SETTINGS, FORMAT, and INTO OUTFILE are rejected anywhere in the statement, including inside a CTE, a subquery, or one branch of a union.

A plain LIMIT n is allowed and can narrow a result, but never widens it: on preview and widget runs it is clamped to 1000.

Filter tokens

Tokens let one query serve many views without being rewritten.

SyntaxMeaning
{{name}}Required. The run fails with UNRESOLVED_TOKEN if no value is supplied and the filter has no default.
[[ ... {{name}} ... ]]Optional. The whole bracketed clause is dropped when no value is supplied.
SELECT
toDate(effective_datetime) AS day,
countIf(v2v_connectivity_status = 'connected') AS connected,
count() AS total
FROM EngagementFactsBoardsView
WHERE effective_datetime >= {{start_date}}
[[AND app_id = {{agent_id}}]]
GROUP BY day
ORDER BY day

With no agent_id supplied, that query runs across every agent. With one, it narrows to that agent. start_date is required either way.

Two things worth knowing:

  • Your SQL is validated twice — once with every optional block kept and once with all of them removed. A query that only parses when a filter happens to be present is rejected when you save it, not at run time when the filter is absent. A dangling WHERE left by a removed block is the usual cause.
  • [[ ]] is only special when it contains a token. ClickHouse nested-array literals like [[1,2],[3,4]] pass through untouched.

Declaring filters is optional

Writing the token into your SQL is enough. When you create or update a widget, every {{token}} in its SQL that the board does not already have a filter for gets one created automatically, labelled with the token name, typed string, with no default.

The filters array on Create widget exists only to give those filters better metadata up front:

{
"filters": [
{ "internal_name": "start_date", "display_label": "Start date", "filter_type": "date", "default_value": "2026-09-01" },
{ "internal_name": "agent_id", "display_label": "Agent", "filter_type": "string", "default_value": null }
]
}

Send it when you want a readable label, a non-string type, a default value, or dropdown options — filter_type is one of number, string, date, or dropdown, and a dropdown must also carry dropdown_options. Omit it and you can set the same things later with Update filter.

Auto-creation never overwrites a filter that already exists, so a token shared by several widgets keeps whatever metadata it was given the first time, and re-saving a widget will not undo your edits.

A filter whose default_value is null and which is used outside an optional block is required — the is_required field on List filters tells you which ones a run must supply. This is why a widget saved with no filters array at all produces required, untyped filters: nothing has given them a default yet.

Supply values at run time keyed by token name, with the declared type alongside the raw value:

{
"filter_values": {
"start_date": { "filter_type": "date", "value": "2026-09-01" },
"agent_id": { "filter_type": "string", "value": "support-agent-v3" }
}
}

Query budget

Each run executes under fixed server-side limits:

LimitValueOn breach
Execution time15 seconds408 TIMEOUT
Rows returned, preview and widget runrow_limit — default 1000, max 10 000truncated: true
Rows returned, tab run1000 per widget, fixedtruncated: true
LIMIT written into your SQLClamped to 1000Silently clamped

Run tab takes no row_limit — the per-widget cap there is fixed and not caller-settable.

Error codes

SQL and execution failures return detail as an object with a code:

{ "detail": { "code": "FORBIDDEN_TABLE", "message": "Table `users` is not available." } }
CodeStatusCauseFix
PARSE_ERROR400Unparseable SQL, multiple statements, or a forbidden clauseCheck the message — it names the clause
NON_SELECT400The statement is not a SELECTRewrite as a read
FORBIDDEN_TABLE400A table outside this workspace’s catalogRe-read Get query schema
UNRESOLVED_TOKEN400A required {{token}} had no value and no defaultSupply it in filter_values, give it a default, or wrap it in [[ ]]
INVALID_FILTER_VALUE400A value does not fit its declared filter_typeSend the type the filter declares
TENANT_ISOLATION_BREACH403The result would have crossed tenantsRemove hand-written tenancy predicates; report it if it persists
TIMEOUT408Exceeded the 15-second budgetNarrow the time range, aggregate earlier, filter on effective_datetime
CLICKHOUSE_ERROR422The database rejected the query — type mismatch, unknown column, bad functionThe clickhouse_error field carries the underlying message

A CLICKHOUSE_ERROR means your SQL passed validation but failed on execution, so it is almost always a column name or a type problem. PARSE_ERROR means it never reached the database.

Next