> For clean Markdown of any page, append `.md` to the page URL.
> For a complete documentation index, see https://docs.sarvam.ai/llms.txt.
> For full documentation content in one file, see https://docs.sarvam.ai/llms-full.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.sarvam.ai/_mcp/server.

# 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](/conversations/monitor/clickhouse-sql-best-practices).

## Start from the schema

Call [Get query schema](/conversations/api/boards/query/schema) before writing anything. It returns the tables available to **this** workspace with their columns and ClickHouse types:

```json
{
  "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:

| Rejected                                       | Why                                                                           |
| ---------------------------------------------- | ----------------------------------------------------------------------------- |
| Anything other than `SELECT`                   | The surface is read-only                                                      |
| Two statements separated by `;`                | One statement per widget                                                      |
| `SETTINGS`                                     | Would override the tenancy, read-only, and timeout settings applied per query |
| `FORMAT`, `INTO OUTFILE`                       | Would control the wire format or write to disk                                |
| `LIMIT BY`                                     | Not supported in widget SQL                                                   |
| `LIMIT` with `OFFSET`                          | Use `row_limit` instead                                                       |
| `LIMIT` that is not a positive integer literal | Must 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.

| Syntax                   | Meaning                                                                                                    |
| ------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `{{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.                             |

```sql
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](/conversations/api/boards/widgets/create) exists only to give those filters better metadata up front:

```json
{
  "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](/conversations/api/boards/filters/update).

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](/conversations/api/boards/filters/list) 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:

```json
{
  "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:

| Limit                                 | Value                                  | On breach         |
| ------------------------------------- | -------------------------------------- | ----------------- |
| Execution time                        | 15 seconds                             | `408` `TIMEOUT`   |
| Rows returned, preview and widget run | `row_limit` — default 1000, max 10 000 | `truncated: true` |
| Rows returned, tab run                | 1000 per widget, fixed                 | `truncated: true` |
| `LIMIT` written into your SQL         | Clamped to 1000                        | Silently clamped  |

[Run tab](/conversations/api/boards/tabs/run) 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`:

```json
{ "detail": { "code": "FORBIDDEN_TABLE", "message": "Table `users` is not available." } }
```

| Code                      | Status | Cause                                                                         | Fix                                                                      |
| ------------------------- | ------ | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `PARSE_ERROR`             | `400`  | Unparseable SQL, multiple statements, or a forbidden clause                   | Check the message — it names the clause                                  |
| `NON_SELECT`              | `400`  | The statement is not a `SELECT`                                               | Rewrite as a read                                                        |
| `FORBIDDEN_TABLE`         | `400`  | A table outside this workspace's catalog                                      | Re-read [Get query schema](/conversations/api/boards/query/schema)       |
| `UNRESOLVED_TOKEN`        | `400`  | A required `{{token}}` had no value and no default                            | Supply it in `filter_values`, give it a default, or wrap it in `[[ ]]`   |
| `INVALID_FILTER_VALUE`    | `400`  | A value does not fit its declared `filter_type`                               | Send the type the filter declares                                        |
| `TENANT_ISOLATION_BREACH` | `403`  | The result would have crossed tenants                                         | Remove hand-written tenancy predicates; report it if it persists         |
| `TIMEOUT`                 | `408`  | Exceeded the 15-second budget                                                 | Narrow the time range, aggregate earlier, filter on `effective_datetime` |
| `CLICKHOUSE_ERROR`        | `422`  | The database rejected the query — type mismatch, unknown column, bad function | The `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

#### [Best practices](/conversations/api/boards/best-practices)

Which endpoint to reach for, caching, and polling.

#### [Preview query](/conversations/api/boards/query/preview)

Run SQL without saving a widget.