Writing board SQL
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:
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:
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.
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
WHEREleft 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:
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. A token name (internal_name) must match ^[a-zA-Z_][a-zA-Z0-9_]*$ and be at most 64 characters.
Every value — a default or a run-time value — is sent as a string and parsed according to filter_type:
A default is not checked when you save it — a date default without an offset only fails when a run falls back to it.
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 is required when at least one widget uses its token outside an optional block — that is what is_required on List filters reports. It does not take the default into account: a required filter with a default_value falls back to that default when a run sends no value, and only a required filter with no default makes the run fail with UNRESOLVED_TOKEN. So the filters a run must supply are the ones with is_required: true and default_value: null — which is every required filter on a widget saved with no filters array, since auto-created filters are typed string with no default.
Supply values at run time keyed by token name, with the declared type alongside the raw value. Values for tokens the board has no filter for are ignored. Preview query has no board and therefore no defaults, so every token outside an optional block must be supplied there.
Query budget
Each run executes under fixed server-side limits:
The effective row limit is the smaller of row_limit and your SQL’s LIMIT. Because an in-SQL LIMIT is itself clamped to 1000, a row_limit above 1000 only helps when the SQL has no LIMIT.
truncated is true whenever the number of rows returned equals the effective limit, so it means the result may have been cut. A query ending in LIMIT 10 that returns exactly 10 rows reports truncated: true even though nothing was lost.
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:
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.
422 is also the status for request validation failures, such as a malformed UUID or a row_limit above 10 000. Those carry detail as a list rather than an object, so check whether detail is a list before reading code.
On Run tab, a widget that fails with any of these codes other than TENANT_ISOLATION_BREACH does not fail the request: it comes back inside the 200 response with status: "error" and an error object carrying the same code and message. A tenant-isolation breach fails the whole tab run with 403.