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
  1. 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.
  2. 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.
  3. 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 withTenant transaction (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.
  4. 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:

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”:

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:

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:

Both land in members.traits_jsonb.scores (alongside a computed_at timestamp) and are immediately usable as segment fields (score_churn, score_propensitygt/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

Next steps