Query language
The JSON behind every chart — trends, funnels, retention, raw events, SQL and game analytics — with filters, breakdowns and date ranges.
Every chart in RoChart is a JSON object with a kind. The insight editor builds it, saved insights store it, and agents send it to the MCP query tool or POST /api/projects/:projectId/query. That means an agent can rebuild any chart you see, and you can open any insight an agent saved. You only send what differs from the defaults.
kind | Answers | Can be saved as an insight |
|---|---|---|
trends | Counts or totals over time | Yes |
funnel | Conversion through ordered steps | Yes |
retention | Do people come back? | Yes |
paths | Common sequences of pages or events | Yes |
lifecycle | New, returning, resurrecting and dormant people | Yes |
stickiness | How many days people did it | Yes |
events | Raw events, newest first | No |
sql | Anything else, in read-only SQL | No |
game_engagement, game_acquisition, game_retention, game_monetization, game_live | The Game analytics page | No |
Every result includes a summary: a few lines of plain text with the numbers that matter.
Building blocks
Date ranges
{ "dateRange": { "from": "-30d" } }from takes -30min, -1h, -3h, -24h, -7d, -12w, -6m, -1y, today, yesterday, mtd, ytd, all, or an ISO date or date and time such as 2026-10-01 or 2026-10-01T09:30. min is minutes and m is months. to is optional and defaults to now. A date in to includes that whole day; a date and time ends exactly then. Ranges and times without an offset follow the project timezone. The default is the last 30 days (7 days for events and game analytics).
{ "dateRange": { "from": "2026-11-02T09:00", "to": "2026-11-02T17:30" } }Intervals
minute, 5min, 10min, hour, day (default), week or month. Weeks start on Monday. Minute intervals work for up to about 1 day (minute), 1 week (5min) and 2 weeks (10min); hourly intervals for up to 31 days. With a minute interval, -1h starts on a minute boundary and -3h on a 5-minute one, so every bucket is whole except the current one.
Filters
{ "type": "event", "key": "$robux", "operator": "gte", "value": 100 }type | Filters on |
|---|---|
event (default) | A property of the event, such as mode or $device_type |
person | The player's current property, such as premium or level |
group | A property of the event's group. Add groupTypeIndex. |
cohort | Membership: operator in or not_in, value the cohort id |
| Operator | Meaning |
|---|---|
eq / neq | Equals / doesn't equal. An array works like in. |
in / not_in | Is (not) one of the values in an array |
contains / not_contains | Contains the text (not case-sensitive) |
regex | Matches a regular expression |
gt / gte / lt / lte | Number comparisons |
is_set / is_not_set | Has a value / doesn't. Leave out value. |
All filters in a list must match. For "either/or", use in with several values, or a cohort that matches any rule.
Breakdowns
{ "breakdown": { "type": "person", "key": "platform", "limit": 5 } }type is event, person, group (add groupTypeIndex) or cohort (with cohortIds, up to 10). limit keeps the most common values (10 by default, up to 50). Empty values show as (none).
Shared fields
trends, funnel, retention and events also take filters (applied everywhere) and cohortId (only people in that cohort).
trends
{
"kind": "trends",
"series": [{ "event": "$purchase", "math": "sum", "mathProperty": "$robux" }],
"interval": "week",
"dateRange": { "from": "-12w" },
"compare": true
}| Field | |
|---|---|
series | 1–10 series: { event, math?, mathProperty?, groupTypeIndex?, filters?, name? }. event: null means all events. |
math | total (default), unique_persons, unique_sessions, unique_groups (with groupTypeIndex), or sum, avg, min, max, median, p90, p99 (with mathProperty) |
interval | See above |
breakdown | Optional |
compare | true adds the previous period |
display | line, area, bar, number or table. Only changes how the app draws it. |
The result has labels (bucket starts) and, per series, data (one value per label) and total (over the whole range). incompleteFrom is the index of a bucket that's still in progress.
$purchase · sum($robux): total 1,811,500; last complete week 427,391 (+0.6% vs previous week); peak 427,391 on 2026-10-26.
The last week is still in progress.funnel
{
"kind": "funnel",
"steps": [{ "event": "$session_start" }, { "event": "tutorial_completed" }, { "event": "$purchase" }],
"windowDays": 7,
"dateRange": { "from": "-30d" }
}| Field | |
|---|---|
steps | 2–12 steps: { event, name?, filters? } |
windowDays | Days to convert from the first step, 1–365 (default 14) |
order | ordered (default) or strict |
breakdown | Optional. The value comes from the first step. |
aggregationGroupTypeIndex | Count groups (such as clans) instead of people |
Each step in the result has count, conversionFromStart, conversionFromPrevious and dropoff.
$session_start 1,759 → tutorial_completed 625 (35.5%) → $purchase 194 (31.0%)
Overall conversion 11.0%. Biggest drop-off: $session_start → tutorial_completed (64.5% lost).retention
{
"kind": "retention",
"startEvent": { "event": "tutorial_completed" },
"returnEvent": { "event": "$session_start" },
"period": "day",
"periods": 8
}period is day, week (default) or month. periods is the number of columns, 2–30 (default 8). Each cohort in the result has a start, a size and values, the number of people who returned in each period. average is the weighted retention per period.
paths
{ "kind": "paths", "include": "events", "startPoint": "$session_start", "steps": 5, "per": "session", "dateRange": { "from": "-7d" } }include is events, pageviews (for website data) or both. startPoint and endPoint take an event name or a page path, and endPoint returns the steps leading up to it. steps is 2–8, and per is session or person. The result has nodes (id is "<step>:<name>"), links between them, and total.
lifecycle
{ "kind": "lifecycle", "event": { "event": "$session_start" }, "interval": "week", "dateRange": { "from": "-8w" } }Returns labels and, per period, new, returning, resurrecting and dormant counts. The current period's dormant is 0 until it ends.
stickiness
{ "kind": "stickiness", "series": [{ "event": "match_ended" }], "interval": "day", "dateRange": { "from": "-30d" } }For each event, counts[i] is how many people were active on exactly i + 1 days (or weeks, with "interval": "week"). periods is the number of days or weeks in the range.
events
{ "kind": "events", "event": "$purchase", "limit": 50 }Optional fields are event, personId, distinctId, groupTypeIndex + groupKey, limit (up to 1,000, default 100) and before. When truncated is true, pass the last row's timestamp as before to get the next page.
sql
{ "kind": "sql", "sql": "SELECT properties.$device_type AS platform, uniq(person_id) AS players FROM events WHERE event = '$session_start' GROUP BY platform" }One read-only SELECT (or WITH … SELECT) over your project's data, in ClickHouse SQL. Times are in your project's timezone, and person_id already accounts for merged identities, so uniq(person_id) counts real people. The SQL Editor lists every table, column and property.
| Table | One row per |
|---|---|
events | Event. event, distinct_id, person_id, session_id, timestamp, properties, device_type, country, group_0…group_4, plus web columns (pathname, referrer_domain, utm_*, browser, os) for website events |
persons | Person. id, properties, created_at |
sessions | Session, by $session_id: started_at, duration_seconds and more |
groups | Clan or other group. group_type_index, group_key, properties |
| Any saved query | Whatever it returns: SELECT * FROM paying_players |
| Shorthand | Means |
|---|---|
properties.mode, properties['my key'] | A property as text |
toFloat64OrNull(properties.$robux) | A property as a number |
person.properties.name (on events) | The player's property |
inCohort(person_id, 3) | Whether the person is in cohort 3 |
A query runs for at most 20 seconds and returns at most 10,000 rows. Add "display": { "type": "line", "x": "day", "y": ["users"] } to say how the result should be charted (table, line, bar or number).
Game analytics
The Game analytics page runs on five kinds, one per tab plus the live strip, computed from the Roblox events with the definitions on that page.
{ "kind": "game_engagement", "dateRange": { "from": "-28d" }, "breakdown": "platform", "filters": [{ "dimension": "join_source", "values": ["friend", "referral"] }] }| Kind | Fields | Returns |
|---|---|---|
game_engagement | dateRange, interval (hour or day), breakdown, filters, compare | DAU, new vs returning, playtime, session time, sessions, MAU and peak concurrent players |
game_acquisition | dateRange, by, filters | New players by first join source, campaign, country, platform or locale, with each source's D1/D7 retention, playtime and revenue per new player |
game_retention | dateRange (cohort days), breakdown, filters, days | D1, D7 and D30 per cohort day, stickiness and the cohort table |
game_monetization | dateRange, interval, filters, compare | Robux revenue, purchases, payers, payer conversion, ARPDAU, ARPPU and top products |
game_live | filters | Players in game now, servers, concurrent players over the last hour, and today so far vs yesterday at the same time |
Filters and breakdowns here use player dimensions instead of event properties: platform, country, locale, join_source, campaign, new_vs_returning, premium or server_type, taken from the session's $session_start. These kinds are new and their fields may still change; the MCP query tool always describes the current ones.
Cohort definitions
Cohorts aren't a query kind, but their rules use the same building blocks:
{
"match": "all",
"criteria": [
{ "type": "behavior", "event": "$purchase", "withinDays": 90, "minCount": 2 },
{ "type": "person_property", "key": "platform", "operator": "in", "value": ["Phone", "Tablet"] },
{ "type": "cohort", "cohortId": 3, "negate": true }
]
}| Rule | Fields |
|---|---|
person_property | key, operator, value |
behavior | event, withinDays (default 30), minCount (default 1), performed (false = did not do it), sumProperty + minSum (a total, such as $robux ≥ 500), filters |
cohort | cohortId, negate |
match is all (default) or any, with 1–20 rules.
Errors
An invalid query returns 400 invalid_query with a message that names the field, and usually a hint:
{ "error": "invalid_query", "message": "Invalid date \"last week\"", "hint": "Use relative values like \"-7d\", \"-24h\", \"-12w\", \"-6m\", \"mtd\", \"ytd\", \"today\", or an ISO date \"2026-01-31\"" }