> 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

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` (at-risk 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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.kula.digital/for-developers/build-your-own-interface.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
