Analytics copilot
The dashboard’s Analytics → Copilot panel answers plain-English questions about your program — “revenue by month for the last 90 days,” “how big is my At Risk segment” — with a real chart and a plain-English readback of what was computed. The model never sees SQL and never writes SQL: it only ever picks a structured intent from a closed vocabulary (a metric, an optional dimension/filter/time window, and a chart type). Your own code validates that intent, compiles it into a parameterized query, and runs it against your tenant’s data. The numbers you see always come from that query — never from the model.
How a question becomes an answer
your question ──▶ model picks a structured intent (IR) ──▶ schema-validated (closed enums)
│
▼
compiler turns the IR into a parameterized SQL query
over YOUR tenant's data (`withTenant`, row-level security)
│
▼
rendered table/chart + a templated plain-English readback
- Model → intent. The model reads your question plus a short system prompt (the vocabulary below) and returns exactly one JSON object — never prose, never SQL — naming a metric, an optional dimension/filter/window, and a chart type.
- Schema validation. That JSON is validated against a closed schema before anything else
happens. Every field is a fixed enum except the filter values (segment/tier/location names),
and those are never interpolated into SQL — the compiler resolves them to ids through
parameterized lookups instead. A schema-invalid or hostile response — including a deliberate
attempt to smuggle SQL into a field, e.g.
metric: "revenue; DROP TABLE members"— is rejected here, before any query runs. - Compile. The validated intent is compiled into a real, parameterized SQL query over the same
computed analytics surface the dashboard’s Analytics page already uses, run inside your tenant’s
withTenanttransaction (the platform’s row-level-security boundary, CLAUDE.md §5). This compiler is the only code path that ever produces SQL for the copilot — there is no path where model output reaches the database directly. A filter name that doesn’t exactly match one of your real segment/tier/location names (including a garbled or injection-shaped attempt) resolves to nothing and comes back honestly unsupported — never a silently wrong all-rows query. - Answer. The query’s result becomes the chart data; a separate templated function — not the model — writes the plain-English readback, so the sentence you read is always a deterministic description of the query that actually ran, never model-generated text.
What it can answer
The model can only choose from this fixed vocabulary — it cannot invent a metric, dimension, window, or chart type that isn’t listed here:
- Metric (pick exactly one):
revenue,transactions,members_active,redemption_rate,member_vs_nonmember_lift,enrollment,points_issued,points_redeemed,segment_count - Dimension (optional, breaks the metric down):
day,week,month,location,tier,segment,channel - Time window (defaults to
last_30d):last_7d,last_30d,last_90d,last_365d,ytd,all - Chart type:
table,line,bar,number
A question can also filter by one of your own segment or tier names (whatever you’ve actually created — “At Risk,” “Gold,” and so on).
Filters and breakdowns aren’t universally supported across every metric — a few real constraints worth knowing before you’re surprised by an honest “unsupported”:
members_active,redemption_rate, andmember_vs_nonmember_liftare tenant-wide scalars for the chosen window — no filter, no breakdown, ever.revenue,transactions,points_issued, andpoints_redeemedsupport aday/week/monthbreakdown and a segment/tier filter; onlyrevenueandtransactions(not the two points metrics) also support a location filter.enrollmentsupports a time breakdown (a weekly signup series) or no breakdown (a by-signup-source split), but no segment/tier/location filter at all.segment_countrequires a segment filter — that’s the whole point of the metric — and supports nothing else.location/tier/segmentwork as filters on the metrics above, not as breakdown dimensions;channelis reserved in the vocabulary but has no compiled query behind it yet. Asking for a channel breakdown, or a breakdown/filter combination outside the list above, comes backunsupported— honestly, never silently ignored or approximated.
Ask a question
Unlike most of the public API, the copilot is a dashboard-only endpoint — it
authenticates with your dashboard session cookie, the same way the Analytics page itself does, not
an organization API key (see the quickstart’s “two kinds of caller” table —
this endpoint only accepts the dashboard kind). Any dashboard role with reports:read can call
it — owner, admin, and manager by default; cashier cannot.
Using the same cookies.txt session from the quickstart’s steps 1–3 (sign up → create organization
→ set active organization):
curl -sS -b cookies.txt http://localhost:8787/v1/analytics/copilot \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:8787' \
-d '{ "question": "revenue by month for the last 90 days" }' | jq .
{
"state": "completed",
"answer_text": "Revenue by month, over the last 90 days.",
"ir": { "metric": "revenue", "dimension": "month", "window": "last_90d", "viz": "line" },
"table": {
"columns": ["month", "revenue"],
"rows": [["2026-05-01", 128340], ["2026-06-01", 141200]]
},
"viz_hint": "line"
}
ir is echoed back so you (and the audit log) can see exactly what the model chose — useful for
sanity-checking that it understood the question. Money-shaped columns like revenue come back in
minor units as integers, the same convention as everywhere else in the API; points columns are
plain integers too; ratio columns like redemption_rate are plain numbers, not currency.
The honesty model
Every answer comes back with a state, and only one of the three ever carries numbers:
state |
Meaning | What you see |
|---|---|---|
completed |
The question mapped to a real query, which ran. | A rendered table/chart plus a plain-English readback — all computed from your data. |
unsupported |
The question doesn’t map to anything in the vocabulary above, or a filter name doesn’t resolve to one of your segments/tiers/locations. | “I can’t answer that from the loyalty data I have — try revenue, members, redemptions, or segments.” No table, no chart. |
unavailable |
The model provider couldn’t be reached (timeout, outage, missing/invalid key) — including the rare case where its response failed our validation. | “I couldn’t reach the model right now — please try again.” No table, no chart. |
unsupported and unavailable never fabricate a number — there is no code path where a failed or
rejected model call produces a chart. The model gateway wraps every provider call in a bounded
retry, and any failure surfaces as a typed error that the endpoint converts straight into the
honest message above, never a guess.
Privacy: what the model sees
The model’s prompt carries exactly two things: your question, and a system prompt listing the closed metric/dimension/window/chart vocabulary plus the names of your own segments, tiers, and locations — the labels you created (“At Risk,” “Gold,” “Downtown”), never member rows. No member data ever crosses into a prompt: phone numbers, emails, loyalty numbers, member ids, balances, and transaction rows are all fetched by the compiled SQL query after the model’s turn is over and the intent is already fixed. The model chooses what shape of query to run; it never sees the data the query returns, and it never sees any individual member’s data at all.
Rate limits
Model calls cost money, so each tenant is capped at 30 questions per 60 seconds (a per-tenant counter, not per-user). Going over the limit returns an honest 429 — “You’re asking a lot — give the copilot a moment and try again.” — rather than queuing or dropping the request silently.
Model provider
The gateway is provider-agnostic and configured per deployment, not per tenant — bring-your-own API key, set once for the whole platform, never a per-merchant setting in v1:
- Anthropic (default) —
claude-sonnet-4-5unless overridden. - OpenAI (supported) —
gpt-4.1-miniunless overridden.
An operator picks the provider and pushes the key as a Worker secret; see infra/DEPLOY.md’s
edge-api leaf for the exact deploy steps. Until a key is set, the copilot still runs end to end —
every question just comes back state:"unavailable", honestly, rather than looking broken.
Churn and propensity scores
Separately from the copilot, every member gets two heuristic scores computed nightly, with no model involved at all — pure statistical formulas over ledger and visit history, the same kind of deterministic batch job that already computes RFM buckets:
churn_risk(0–1): higher means more likely to lapse — driven by days since last visit (exponential decay), visit frequency, and tenure.propensity(0–1): higher means more likely to respond to an offer — driven by RFM standing, recency, and redemption engagement.
Both land in members.traits_jsonb.scores (alongside a computed_at timestamp) and are
immediately usable as segment fields (score_churn, score_propensity — gt/lt/gte/lte,
null-safe: a member who hasn’t been scored yet simply falls out of any threshold rather than
matching one). Treat them in the dashboard as directional heuristic estimates, not a guarantee — a
high churn_risk is “worth a look,” not a certainty. Don’t confuse the two features: the copilot
calls a model, the scoring cron never does.
Audit log
Every question — answered, unsupported, or unavailable — writes an ai_audit row before returning:
the question text, the compiled ir (when there was one), a bounded summary of the result (row and
column counts, never the actual data), the provider/model, latency, and outcome. This is a separate
trail from the MCP server’s agent_audit — the copilot is a
dashboard-user action, MCP is an external agent’s — kept honestly distinct rather than merged.
There’s no dedicated dashboard page for it yet; query the table directly if you need the history.
v1 limits
- Stateless, one question at a time — there’s no conversation memory; each question is independent, with no way to ask a follow-up that references a previous answer.
- Env-level provider, not per-tenant — every tenant on a deployment shares the same configured provider and key; a merchant bringing their own key is out of scope for now.
- No dedicated audit-log UI —
ai_auditrows exist and are tenant-isolated, but the dashboard only surfaces the copilot’s live answer, not its history. - The scoring cron (
churn_risk/propensity) is pure arithmetic, not a prediction from the same gateway the copilot uses — see above.
Next steps
- API reference — the full request/response schema for
POST /v1/analytics/copilot. - AI agents (MCP) — the platform’s other AI-adjacent surface, for external agents rather than the dashboard.
infra/DEPLOY.md— deploy-time setup for the model gateway (AI_PROVIDER/AI_API_KEY/AI_MODEL, theCOPILOT_KVrate-limit binding).