Analytics SQL
The tables, template variables and rules behind execute_index_sql and custom report charts.
Custom report charts and the execute_index_sql MCP tool run read-only SQL
against your organization's analytics tables. Access is scoped by Postgres Row
Level Security: you only ever see your own organization's rows.
Rules
- One
SELECT(orWITH ... SELECT) statement, no trailing semicolon. - Write
snake_casein SQL. Returned row keys are camelCased (conversation_id→conversationId), which is what chart series keys use. - Results are capped at 1,000 rows, ~1 MB of JSON and a 10 second timeout.
Add
LIMITandWHEREclauses rather than post-filtering. - Only the tables below are accessible. Anything else fails with
permission denied.
Template variables (report charts only)
A saved chart's SQL may use these placeholders. They are substituted with SQL literals from the report toolbar, so write them without quotes:
| Variable | Resolves to |
|---|---|
{{from}}, {{to}} | Start and end of the selected period, as timestamptz |
{{tz}} | Selected IANA timezone, e.g. 'Europe/Vilnius' |
{{bucket}} | 'day', 'week' or 'month' |
{{business_ids}} | Selected businesses as a uuid[] |
select date_trunc({{bucket}}, created_at at time zone {{tz}}) as bucket,
count(*) as conversations
from conversation_index
where created_at >= {{from}} and created_at < {{to}}
and business_id = any({{business_ids}})
group by 1 order by 1A chart that does not use {{from}}/{{to}} ignores the date range control.
Conversation tables
| Table | One row per | Notes |
|---|---|---|
conversation_index | conversation | Volume, handoffs, channels, tags, ratings, sentiment, orders. Exclude is_spam / is_hidden for customer-facing numbers. |
agent_index | agent × conversation | Message and note counts, reply times. |
agent_index_daily | agent × day | Message counts. |
message_index | message | conversation_id, created_at, sender, text. |
conversation_purchase_index | purchase × conversation | Attributed purchases: purchase_at, total_price, currency_code, order_id, provider, line_items, seconds_after_conversation, conversation_rank. |
A purchase attributes to every conversation that preceded it in the same
browser session. When summing revenue filter conversation_rank = 1 or you
double-count, and exclude provider values ending in -staging (test orders).
seconds_after_conversation <= N * 86400 applies an N-day attribution window
(the dashboard default is 7 days).
Storefront search tables
All four are keyed by business_id; there is no organization_id column.
| Table | One row per | Kept |
|---|---|---|
search_daily_stats | business × UTC day × surface | forever |
search_query_daily_stats | business × UTC day × normalized query | forever |
search_query_logs | search | 14 days |
search_click_logs | product opened from a search | 14 days |
search_daily_stats columns: date, surface (panel, fullSearch,
voice, image, restApi, …), searches, zero_result_searches,
relaxed_searches, clicked_searches, clicks, sum_result_count,
sessions, understanding_cache, understanding_llm,
understanding_lexicon_only, median_total_ms, p95_total_ms,
sum_total_ms, timed_searches.
search_query_daily_stats columns: date, normalized_query,
sample_query (a readable spelling), searches, zero_result_searches,
clicked_searches, clicks, sum_result_count. This is the source for top
and zero-result query reports.
search_query_logs columns: query, normalized_query, surface,
session_id, result_count, shown_product_ids, query_understanding,
understanding_source, relaxed, total_ms, created_at.
search_click_logs columns: search_id, search_created_at, product_id,
position, query, normalized_query, surface, session_id, created_at.
Guidance:
- Prefer the daily stats tables for anything charted; the raw logs are gone after a fortnight.
- The daily tables have a
datecolumn instead ofcreated_at. Filter withdate >= {{from}}::date and date < {{to}}::dateand bucket withdate_trunc({{bucket}}, date). - Counts re-aggregate across days with
sum().median_total_msandp95_total_msdo not; chart them per day or usesum(sum_total_ms) / sum(timed_searches)for a mean. - Click-through rate is
clicked_searches / searches.clicks / searchesexceeds 1 and is an engagement measure, not a rate.
Virtual try-on tables
Keyed by business_id; there is no organization_id column.
| Table | One row per | Kept |
|---|---|---|
virtual_tryon_daily_stats | business × UTC day × surface | forever |
virtual_tryon_generations | try-on attempt | forever |
virtual_tryon_daily_stats columns: date, surface, attempts,
successes, failures, failures_by_reason (a {reason: count} jsonb bag),
unique_product_urls, median_duration_ms, p95_duration_ms,
sum_duration_ms, timed_successes.
virtual_tryon_generations columns: created_at, product_url,
product_id, config_id (which try-on variant matched the page), status
(success / failure), failure_reason (productNotFound,
productHasNoImage, imageFetchFailed, baseImageUnavailable,
noImageFromModel, unexpectedError), duration_ms, surface.
Guidance:
surfaceisstorefront(a real shopper) orpreview(a merchant testing a prompt in the dashboard, against one product URL, repeatedly). Filtersurface = 'storefront'for anything describing usage, especially "most tried-on products".- Shopper usage is
status = 'success'; the success rate issuccesses / attemptsfrom the daily table. - Unlike search, the raw table is never pruned, so product-level history goes back to the first try-on. Use it for "top products" over long windows.
- Rows written before 2026-09-22 all read as successful storefront try-ons —
that is what the table recorded then — and have no
duration_ms,config_idorproduct_id. Filterduration_ms is not nullfor latency, or usetimed_successesas the denominator. - Latency covers successes only, and counts the whole attempt (catalog lookup, image picker, generation) — i.e. what the shopper waited.
unique_product_urlsdoes not re-aggregate across days; count distinct on the raw table for a window-wide figure.- Outfit-builder variants distort "top products". A look is several
products but one row:
product_urlis the builder page (the same URL for every attempt) andproduct_idis only the first slot's product. Exclude those variants'config_ids from product-level reporting, or read the numbers as attempts-per-page rather than per-product.
Table charts
For table charts, chartConfig.tableLinks maps a result column to a link
behaviour: {type:"url"} uses the cell value as the URL,
{type:"conversation"} treats it as a conversation public ID and opens
https://app.octocom.ai/organization/{organizationSlug}/conversation/{publicId},
and {type:"template", template:"..."} builds a URL from {{value}},
{{organizationSlug}} and other result-column names. Plain http(s) URL cells
and publicId columns are linked automatically.