> For the complete documentation index, see [llms.txt](https://docs.kula.digital/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kula.digital/for-developers/build-your-own-interface.md).

# Build your own interface

A developer guide to building a custom app or UI directly on the Kula Intelligence MCP — connection and scopes, execute\_query gotchas, the unified schema, and a worked reference implementation.

Kula Intelligence is a **read-only AI layer** over a studio's operational data: a Model Context Protocol (MCP) control plane that normalises GymMaster / Mindbody / Wix / Stripe / Xero etc. into one schema and exposes it through a small set of safe, org-scoped tools.

Any client that speaks MCP can read it — Claude has its own [connect guide](/connect-claude-and-access/claude.md), and Cursor, Lovable and other clients are covered in [Connect](/for-developers/connect.md). **This guide is for developers building their own app or UI directly against the MCP** — a custom dashboard, a payroll tool, an internal report. A complete, open-source worked example exists: the **Instructor Bonus Builder** ([github.com/kulaforbusiness/instructor-bonus-builder](https://github.com/kulaforbusiness/instructor-bonus-builder), [live demo](https://instructor-bonus-builder.vercel.app)).

***

## 1. Get a connection (endpoint + token)

In your operator console (**kula-org-admin**): **Settings → Integrations → MCP Tokens**.

1. **Create a token.** Pick the **scope** matching what your app does:

   | Scope        | Sees                             | Use for                                 |
   | ------------ | -------------------------------- | --------------------------------------- |
   | `analytics`  | aggregates only, no names        | charts, totals, anonymous analysis      |
   | `operations` | names, no emails/phones          | most apps — rosters, schedules, reports |
   | `admin`      | + emails/phones                  | CRM-style tools, member contact         |
   | `full`       | + payment details (audit-logged) | only when you genuinely need raw PII    |

   Least privilege wins — `operations` or `admin` covers most products.
2. The console shows the **JWT token** and the exact **endpoint URL** to use — e.g. `https://mcp.kula.digital/mcp` (or your region's, such as `https://au-mcp.kula.digital/mcp`). You need both.

The token encodes your `org_id` and `scope` — **everything is auto-scoped to your org**; you never pass an org id. Treat the token like a database password: store it in a secret, not in code; rotate it every \~90 days; use a **dedicated token per app** (tokens are rate-limited independently, and it keeps billing/audit clean).

***

## 2. The protocol

Standard MCP over HTTP (JSON-RPC 2.0). Authenticate with `Authorization: Bearer <token>`.

* `tools/list` — discover the full tool catalogue (your client registers these).
* `tools/call` — invoke a tool with arguments.

Discover what's available first:

```bash
curl -s https://mcp.kula.digital/mcp \
  -H "Authorization: Bearer $KULA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

The tools you'll use most:

* **`list_tables`**, **`get_table_schema`**, **`get_semantic_catalogue`** — explore the data model (tables, columns, typical queries).
* **`execute_query`** — run read-only SQL (the workhorse; see §3).
* **`build_query`** — a structured SELECT builder if you'd rather not write raw SQL.
* **`get_business_context`** — operator-authored studio facts (class mix, goals, pricing).
* **Graph tools** — `get_staff_context`, `get_staff_concentration`, `get_member_affinity`, `get_open_signals` (attention-signal queue), `simulate_departure` (what-if a staff member leaves).

***

## 3. `execute_query` — the workhorse, with the gotchas

Most custom apps are built on `execute_query`. These are the things that will trip you up if you don't know them (they're not obvious):

* **Argument name is `sql`** (not `query`): `{"name":"execute_query","arguments":{"sql":"SELECT ..."}}`.
* **Read-only** — `SELECT` / `WITH` only.
* **Event tables are read-guarded.** Querying `bookings.class_session` or `bookings.attendance` directly fails. Use the **`_guarded` views**: `bookings.class_session_guarded`, `bookings.attendance_guarded` (they hide data newer than midnight-today in the org timezone). Dimension tables like `people.location` / `people.staff` are **not** guarded.
* **500-row hard cap per call.** No argument lifts it — only SQL `OFFSET` works. Page every bulk query: `... ORDER BY <stable unique key> LIMIT 500 OFFSET <k>`, looping until a page returns < 500 rows. A single un-paged query on a busy studio silently truncates (classic symptom: classes show but attendance/retention come back empty).
* **Result shape.** Rows arrive as a text content part whose `text` is JSON: `{ "rows": [...], "row_count": N, "columns": [...] }`. Parse `rows` from there.
* **Errors come back HTTP 200** with `result.isError === true` and the message in `result.content[0].text` — check it and surface it, don't treat it as empty.
* **Rate limit** \~120 req/min per token (HTTP **429**). Back off (exponential + jitter, honour `Retry-After`) and **serialise** heavy paging rather than firing it all at once.
* **PII is redacted by scope** at request time — an `analytics` token literally cannot see names; `operations` can't see emails. Pick the scope your UI needs.

A robust `executeQuery` helper (TypeScript) looks like:

```ts
async function executeQuery(sql: string): Promise<Record<string, unknown>[]> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(ENDPOINT, {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call",
        params: { name: "execute_query", arguments: { sql } } }),
    });
    if ((res.status === 429 || res.status === 503) && attempt < 7) {
      const ra = Number(res.headers.get("retry-after"));
      await new Promise(r => setTimeout(r, (ra > 0 ? ra * 1000 : Math.min(10000, 500 * 2 ** attempt)) + Math.random() * 400));
      continue;
    }
    if (!res.ok) throw new Error(`MCP HTTP ${res.status}`);
    const json = await res.json();
    if (json.result?.isError) throw new Error(json.result.content?.[0]?.text ?? "query failed");
    const text = json.result?.content?.[0]?.text;
    return text ? JSON.parse(text).rows ?? [] : [];
  }
}

// Page past the 500-row cap with a stable, unique ORDER BY:
async function fetchAll(baseSql: string, orderBy: string) {
  const all: Record<string, unknown>[] = [];
  for (let offset = 0; ; offset += 500) {
    const page = await executeQuery(`${baseSql}\nORDER BY ${orderBy}\nLIMIT 500 OFFSET ${offset}`);
    all.push(...page);
    if (page.length < 500) break;
  }
  return all;
}
```

***

## 4. The unified schema (orientation)

Everything is normalised into a small set of family-canonical schemas, regardless of the underlying vendor:

* **`people`** — `member`, `staff`, `location`, `identity_link`
* **`bookings`** — `class_session`, `attendance`, `appointment`, `facility_entry`
* **`commerce`** — `sale`, `refund`, `plan`
* **`graph`** — derived rollups: `staff_concentration`, `member_affinity`, `risk_signal`

Two things to know about keys:

* Every row carries `source` (e.g. `com.gymmaster`) and `source_external_id` (the vendor's id). **Joins match on both** — keys are only unique *with* their source.
* `attendance.member_id` / `attendance.class_session_id` are loose FKs to the `source_external_id` of the member / class. But the **graph tools key on the canonical `people.staff.id`**, not `source_external_id` — resolve it first (`SELECT id FROM people.staff WHERE source_external_id = …`) before calling `get_staff_context`.

Run `get_semantic_catalogue` for the authoritative, annotated column list.

***

## 5. The pattern that matters: your code computes, the AI narrates

The most important design lesson from the reference build: **don't ask an AI to compute the numbers your users will act on.** Earlier attempts that let an LLM "judge" performance produced figures nobody could trust or defend.

Instead:

1. Use the MCP (`execute_query`, graph tools) to **fetch** clean, org-scoped data.
2. Do the decision logic in **your own deterministic, testable code** (the Bonus Builder has a pure, zero-dependency engine with hand-checked + golden-master tests).
3. Use an LLM only for what it's good at — **explaining** the result in plain English and **routing** the user — never for the arithmetic.

Structure your app with a **data-adapter seam**: one function that turns MCP rows into your domain objects. Then the same logic runs against live data, a CSV export, or test fixtures.

***

## 6. Security for hosted apps

* **A browser-only token is fine for a local/single-user tool.** For anything **hosted and shared**, do not ship the token to the browser — proxy the MCP call through a small server or edge function with the token held as a **server-side secret**. (The Bonus Builder includes a Supabase edge-function proxy you can copy.) This also sidesteps CORS.
* Scope to least privilege; rotate tokens; one token per app.

***

## 7. Reference implementation

**Instructor Bonus Builder** is a complete, open-source app built exactly this way — a deterministic engine that splits a bonus pool across instructors, fed by an MCP adapter with the pagination + retry + guarded-view handling above, plus a CSV fallback and a swappable theme.

* Code: <https://github.com/kulaforbusiness/instructor-bonus-builder>
* Live: <https://instructor-bonus-builder.vercel.app>
* The MCP adapter (the bits in §3, productionised): `packages/adapters/src/`

Clone it as a starting point, or read it to see the patterns in context.
