# What Kula Intelligence is

AI that actually knows your studio — a shared, always-current view of the whole business, answered in plain English in the assistant you already use, scoped so each teammate sees only what they're all

**Kula Intelligence is AI that actually knows your studio.**

You already use an AI assistant — Claude, maybe ChatGPT. It gives great general advice and useless specific answers, because it can't see *your* numbers. Kula Intelligence fixes that. It gives your AI assistant a safe, private window into your studio's own data, so when you ask **"who haven't I seen on the mat lately?"** you get an answer built from your members, your attendance, your sales — not generic fitness-industry advice.

You ask questions in plain English, in the assistant you already pay for. No new dashboard to learn. No spreadsheets.

![The Kula Intelligence console — where you connect data, manage AI access, and see your studio's status](/files/pFX89dHGgMnAoCLQOdAB)

## One shared brain for your whole studio

Kula Intelligence isn't a private window for one person — it's the **shared brain for the whole business**, and it's **always up to date**. It reads straight from the tools you already run on — your booking platform, your payments, your accounting — and keeps itself current, so there's no spreadsheet to refresh and no export to remember. Whenever you ask, you're asking the live state of the studio.

* **A whole-of-business view.** Everything you connect sits in one place, so a single question can cross bookings, sales, and accounting at once — instead of three dashboards that don't talk to each other.
* **Your team, each seeing only what they should.** Give a coach, a manager, or a bookkeeper their own access, set to the right level — some see trends only, some see member names, some see contact details. The assistant can never reveal more than that person's level allows, so the whole team can share one brain without sharing everything. See [Permission levels](/connect-claude-and-access/scopes).
* **Everyone on the same page.** The playbooks you build are **shared skills**: publish one and everyone connected to the studio runs the exact same analysis the same way — so your team is always looking at the same thing, not ten slightly different versions of it. See [What skills are](/skills/skills).

And soon you'll be able to **save an answer and share it** with others on your team, so a useful result doesn't stay locked in one person's chat.

## Who it's for

Owners and operators of boutique fitness studios — reformer pilates, yoga, strength and group training, climbing, boutique cycle. The people who spend Sunday evenings staring at three dashboards trying to work out whether next month is going to be okay.

You do **not** need to be technical. If you can install an app and copy and paste, you can set this up yourself in about ten minutes. A consultant can do it *with* you or *for* you if you'd rather — but nothing here requires one.

## What you can ask

Once your data is connected, you ask things like:

* *"Who'd love a check-in from me this week?"*
* *"How is the reformer cohort tracking — are they staying with us?"*
* *"Which classes are quietly growing, and which could use a refresh?"*
* *"Draft a warm note to the members I haven't seen since May."*
* *"Are we going to be okay next month?"*

See [What you can ask](/get-started/ask) for the full library, grouped by the problem you're trying to solve.

## How it works

1. **Create your account and connect your data.** Plug in the tools you already use — your booking platform, your payments, your accounting. It's a guided setup; you paste a key or click through a sign-in, and we pull your history in. See [Set up & get your first answer](/get-started/start).
2. **Connect your AI assistant.** One link, one sign-in. See [Connect Claude](/connect-claude-and-access/claude).
3. **Ask questions.** In Claude or ChatGPT, just like you talk to it now — except now it can see your studio.
4. **Stay in control.** You can see exactly what's been read and when, and you can disconnect any time with one click. See [Is my data safe?](/get-started/trust).

## What makes it trustworthy

* **Your data is yours alone.** Every studio gets its own private database. There is no path for one studio's question to be answered with another studio's data — they aren't even stored together. Read [Security & data handling](/trust-and-legal/security).
* **It reads, it doesn't spend.** Kula Intelligence can draft a win-back email; it never sends money, never charges a card, never posts on your behalf.
* **You decide how much it can see.** Permission levels let you keep member names and contact details hidden when you only need the big picture. See [Permission levels](/connect-claude-and-access/scopes).

## What it isn't

* **Not a new CRM.** It doesn't replace your booking platform or payment processor. It reads from them.
* **Not another dashboard.** The interface is the AI assistant you already use. We show no charts.
* **Not a marketing autopilot.** It can write the email; you send it.

## Where to go next

* **Setting it up yourself?** → [Set up & get your first answer](/get-started/start)
* **A consultant helping a studio?** → [Helping a studio go faster](/for-consultants/consultants)
* **A developer integrating it?** → [Developer overview](/for-developers/overview)

***

*Kula Holdings Pty Ltd · Sydney*


# Set up & get your first answer

The full setup, start to finish — no technical help needed. About 15 minutes to your first real answer.

This is the whole journey, start to finish. You can do every step yourself — no developer, no consultant. Budget about **15 minutes**, most of which is waiting for your history to import.

If you'd rather have someone do the setup with you or for you, a consultant can — see [Helping a studio go faster](/for-consultants/consultants). Nothing below requires one.

## What you'll need

* An email address to create your account.
* Login access to **one** of the tools your studio already runs on — your booking platform, your payments, or your accounting. One source is enough to get a first answer; you can add more later.
* An AI assistant you already use, or are happy to install — **Claude Desktop** is the simplest starting point.

## Step 1 — Create your account (2 min)

Go to [**console.kula.digital**](https://console.kula.digital) and sign up. You'll land in a short setup wizard that asks a few plain questions:

* Your studio's name, region, and timezone.
* What kind of studio you run (yoga, reformer, strength, climbing…).
* What you're allowed to let the AI read (you stay in control — see [Is my data safe?](/get-started/trust)).

Behind the scenes we create a **private database just for your studio**. You'll see a short "setting up" screen while that happens. When it's done, you're in.

![The Kula Intelligence console you land on after signing in](/files/pFX89dHGgMnAoCLQOdAB)

This is your home base — connectors, AI access, and your studio's data status all live here. The rest of the steps below start from this screen.

## Step 2 — Connect your first data source (5 min + import time)

From your dashboard, open **Connectors** and pick the source you want to start with. Each one has its own short guide for where to find the key or how to sign in:

* [Stripe](/your-data-sources/stripe) — payments and subscriptions
* [Wix](/your-data-sources/wix) — bookings, members, online store
* [Mindbody](/your-data-sources/mindbody) — bookings and memberships
* [Xero](/your-data-sources/xero) — accounting
* [Meta](/your-data-sources/meta) — Facebook & Instagram marketing
* [Google Analytics 4](/your-data-sources/ga4) — website traffic
* [CSV & ClassPass](/your-data-sources/csv) — upload a spreadsheet

The pattern is the same for all of them: **Test → Connect → Import**. You test that your key works, connect, then pull in your history (we suggest starting with the last 12 months). The import runs in the background — a big studio can take 20–30 minutes, so this is a good time for a coffee.

> Don't worry about getting everything connected today. One source gives you real answers. You can add the rest whenever you like.

## Step 3 — Connect your AI assistant (2 min)

Now connect Claude (or another assistant) to your studio. In **console.kula.digital → Connectors**, click **New invite** — its **permission level** is set by you, and for your own use **Operations** is the right default (the AI sees member names but not emails, phones, or payment details).

Then either:

* **Email it to them** from that same screen. They get an invite with an **Open in Claude** button — one click opens Claude with the connector already filled in, and they just review and approve. This is the easiest way to get a team member connected.
* **Copy the connect link** and add it to Claude yourself.

Full steps are in [Connect Claude](/connect-claude-and-access/claude); the levels are explained in [Permission levels](/connect-claude-and-access/scopes).

## Step 4 — Ask your first question

Open Claude and ask something real:

> *"List the tools you can use from Kula Intelligence."*

That confirms the connection. Then ask a question about your studio:

> *"How many members have we got, and how has attendance moved over the last 12 months?"*

If the numbers look right, you're connected and reading your own data. Head to [What you can ask](/get-started/ask) for the question library — it's free and there's no limit to what you can ask. When you want repeatable playbooks, the [built-in skills](/skills/library) run the same analysis the same way every time.

## If something doesn't work

* **The AI says it can't see any tables** → your import may still be running, or the data source needs a moment to finish. Give it a few minutes, then ask again.
* **A "401" or "sign-in expired" message** → your connect link needs renewing; create a fresh connector in **console.kula.digital → Connectors**.
* Anything else → [Troubleshooting](/connect-claude-and-access/troubleshooting).

## What's next

* [What you can ask](/get-started/ask) — the free question library by problem.
* [What skills are](/skills/skills) — saved, repeatable playbooks the AI can run on your studio.
* [Your weekly rhythm](/get-started/your-rhythm) — keep getting value, week after week.


# What you can ask

A library of plain-English questions grouped by the problem you're trying to solve — with what to expect back.

You ask in plain English, the way you'd ask a sharp ops manager who knows your numbers. Below are starting points grouped by the problem you're trying to solve, with a note on what each one gives you back.

You don't have to phrase them exactly like this. Ask follow-ups. Push back. The AI is reading *your* data, so the more specific you get, the better the answer.

## Keeping members (retention & at-risk)

* *"Who'd love a check-in from me this week?"*
* *"Which members haven't I seen in the last 30 days who used to come regularly?"*
* *"How is the reformer cohort tracking — are they staying with us?"*
* *"Show me members whose attendance has quietly dropped off in the last two months."*

**What you get back:** a named list (when your permission level allows names) of specific members, with how long since their last visit and what changed, so you can reach out today — not a generic "improve retention" tip.

## Your classes & schedule

* *"Which classes are quietly growing, and which could use a refresh?"*
* *"What are my busiest and emptiest time slots over the last quarter?"*
* *"Is the new instructor's class finding its people?"*
* *"Which classes should I think about cancelling based on attendance trends?"*

**What you get back:** attendance trends by class and time slot, with the direction of travel — so you can make schedule calls on evidence, not gut.

## Money & cash (revenue, plans, payments)

* *"How did revenue move over the last 12 months?"*
* *"How do memberships compare to class packs for us — which holds people longer?"*
* *"Are we going to be okay next month?"*
* *"Which members are on plans that are about to lapse?"*

**What you get back:** revenue and plan trends drawn from your own payments and accounting, reconciled — with the gaps flagged where the data can't fully answer, rather than a confident guess.

## Members & outreach

* *"Draft a warm note to the members I haven't seen since May."*
* *"Who are my most loyal members I should thank this month?"*
* *"Pull together a list of members due for a plan review."*

**What you get back:** a ready-to-send draft and the list it's based on. **It writes the message; you send it** — Kula Intelligence never emails or messages members on your behalf.

## Marketing & acquisition

* *"Where are my new members actually coming from?"*
* *"How is our website traffic trending, and is it turning into bookings?"*
* *"What did we spend to acquire members last quarter, and was it worth it?"*

**What you get back:** acquisition and traffic trends from your connected marketing sources. If a source isn't connected yet, the AI tells you what's missing rather than inventing a number.

## When the answer says "I'm not sure"

A good answer sometimes is *"I can't fully answer that yet."* If a data source isn't connected, or your history has a gap, the AI will say so and tell you what to connect or fix. That honesty is the point — see [Keeping your connectors healthy](/your-data-sources/operating) to close gaps.

## What's next

* [Your weekly rhythm](/get-started/your-rhythm) — turn these into a habit.
* [Build your own & share with your team](/skills/build-your-own) — save your best questions as a repeatable skill.


# Is my data safe?

The short, plain-English version of how we protect your studio's data — and where to read the detail.

Short answer: **yes, and you stay in control.** Here's the plain-English version. The full detail is in [Security & data handling](/trust-and-legal/security) and our [Privacy policy](/trust-and-legal/privacy).

## Your data is yours alone

Every studio gets its **own private database**. Your numbers are never mixed with another studio's. There is no way for the AI to answer one studio's question using another studio's data — they aren't even stored in the same place. This isn't a setting you have to switch on; it's how the whole thing is built.

## It reads, it doesn't spend

Kula Intelligence reads from the tools you connect so it can answer questions. It will happily draft a win-back email for you — but **you** send it. It never moves money, never charges a card, never posts to your socials, and never messages your members on its own.

## You decide how much it can see

Every connector you create is set to a **permission level**. The everyday default keeps member emails, phone numbers, and payment details hidden — the AI sees enough to help, not more. You only create a higher-level connector for a specific job that needs it. See [Permission levels](/connect-claude-and-access/scopes).

## You can see what happened, and stop it anytime

* Every connection shows you what was read and when.
* You can disconnect a data source or revoke your AI assistant's access with one click. Access stops within about a minute.
* Privileged actions are recorded, so there's always a trail.

## Where your data lives

Your studio's data is stored in the cloud region you're set up in (Australian studios stay in Australia). We use trusted infrastructure providers (for databases, hosting, and sign-in) and never sell your data. The full list of providers and how long we keep things is in the [Privacy policy](/trust-and-legal/privacy).

## Have a tougher question?

If you (or your accountant, or a security-minded client) want the technical detail — encryption, isolation, how access is revoked, what we deliberately *don't* do — it's all in [Security & data handling](/trust-and-legal/security). Found a security issue? See [Responsible disclosure](/trust-and-legal/disclosure).


# Your weekly rhythm

A simple habit that keeps Kula Intelligence useful long after setup — what to check, what to ask, and where things live.

Setup is a one-time thing. The value comes from a small **weekly habit**. This page is the habit — built so you can keep getting answers on your own, with no one's help.

Pick a quiet 15 minutes — many owners do it Sunday evening or Monday morning — and run through this.

## A 15-minute weekly check

**1. Ask the three questions that matter most to you.** Most owners settle on a short set they ask every week. A good starting trio:

* *"Who'd love a check-in from me this week?"*
* *"How did attendance and revenue move versus last week?"*
* *"Anything I should be worried about that I might not have noticed?"*

**2. Act on one thing.** Don't try to fix everything. Pick the single clearest signal — usually a few members worth a personal note — and ask:

* *"Draft a warm message to those members."* Then send it yourself.

**3. Glance at your connectors.** Once a week, make sure your data is still flowing. On your dashboard, the **Connectors** page shows the status of each source. If something needs attention, see [Keeping your connectors healthy](/your-data-sources/operating) — it walks you through gaps and reconnecting.

That's it. Three questions, one action, one glance.

## Make it yours

As you find the questions that genuinely move your week, **save them as a skill** so you (or anyone on your team) can run the whole set with one ask. See [Build your own & share with your team](/skills/build-your-own).

If you brought a consultant in to set things up, this is the page they hand you on the way out — it's everything you need to keep going on your own. See [Hand off cleanly](/for-consultants/hand-off).

## Monthly, not just weekly

Once a month, ask for the bigger picture:

* *"Give me a month-on-month view of members, attendance, and revenue."*
* *"What's changed since last month — members, attendance, revenue?"*

A monthly work-up like this is worth repeating — it picks up new data and tells you what moved.

## If you ever feel stuck

* Not sure what to ask? → [What you can ask](/get-started/ask)
* An answer looks off? → [Troubleshooting](/connect-claude-and-access/troubleshooting)
* Want to add another data source? → [How connectors work](/your-data-sources/connectors)


# Connect Claude

One click from the invite adds Kula to Claude with everything pre-filled — or paste the connect link in by hand. Either way access is controlled by your rules; there's no direct, open connection.

Kula controls access by **rules and roles** — so you never point Claude at Kula directly. Instead there's a **personal connect link**, created in **console.kula.digital**, that already carries exactly what that person is allowed to see. Adding it to Claude is what connects them. There is no open, direct address to connect to; the connect link is the controlled door in.

There are two ways to add it, and **both end up in the same place**:

* **One click** — the invite has an **Open in Claude** button that opens Claude with the connector already filled in. You just review and approve.
* **By hand** — the same screen also shows the connect link, so you can copy it into Claude yourself.

## If someone sent you an invite

The fastest path. You'll have an email (or a link to the connect page) with a big **Open in Claude →** button.

1. Click **Open in Claude →**. Claude opens — the desktop app or claude.ai — with its **Add custom connector** dialog already filled in: the connector name (`Kula — <your studio>`) and the **Remote MCP server URL** (your connect link). Nothing to type, nothing to paste.
2. Review what it says and click **Add**, then **Connect**.
3. **Approve** the connection on the consent screen — it shows the studio's name and the access level being granted.

You're returned to Claude and the connector shows as connected. Skip to [Check it worked](#check-it-worked).

> **Prefer to do it by hand?** You don't lose that option. The email and the connect page both still show your connect link in full, with the manual steps underneath — see [Add it by hand](#add-it-by-hand). Nothing about the one-click button changes what's being granted; it fills in the same two fields you'd type yourself.

The invite also tells you two things worth noting: whether the link is **single-use** (spent the moment it's connected) or **reusable**, and the date it **expires**. If it lapses before you get to it, ask whoever sent it to hit **Extend** — the link you were already sent keeps working, it just gets a new deadline.

## If you're creating the link yourself

1. Sign in to **console.kula.digital**.
2. Open **Connectors** (*"Claude connector invites"*) and click **New invite →**. The **permission level** you choose decides what the connection can access — see [Permission levels](/connect-claude-and-access/scopes).
3. You now have a connect link that looks like:

   ```
   https://mcp.kula.digital/connect/XXXXXXXX/mcp
   ```

   Treat it like a password — it's scoped to a role, but it's still a credential.
4. Either **email it to them** from that screen — they get the invite with the **Open in Claude** button — or copy the link and add it to Claude yourself with the steps below.

> Why a link and not an open address? Because access is the whole point. Every connection runs through a role-scoped link, so what each person can see stays under your control. We don't allow direct access to the data.
>
> Connecting Claude on **your own machine**, or using a paste-a-token client like Cursor or Lovable? Mint a **bearer token** under **Tokens** instead — connector invites are for sending access to someone else. See [Connect — API, Code, Cursor, Lovable](/for-developers/connect).

## Add it by hand

1. In **Claude Desktop** or **claude.ai**, open **Settings → Connectors → Add custom connector**.
2. Fill in two fields:
   * **Name:** `Kula Intelligence`
   * **Remote MCP server URL:** your **connect link** (`https://mcp.kula.digital/connect/…/mcp`).
3. **Leave the Advanced settings (OAuth Client ID / Secret) blank** — Claude registers itself automatically; filling those in breaks the connection.
4. Click **Add**, then **Connect**. A browser window opens.
5. **Sign in and approve** the connection on the consent screen — it shows the studio's name and the access being granted.

You're returned to Claude and the connector shows as connected.

## Check it worked

Start a new chat and ask:

> *"List the tools you can use from Kula Intelligence."*

Claude should return the full list of tools. If it does, you're connected — and only seeing what your role allows.

For the best answers, pick Claude's most capable model. Then try something real about the studio:

> *"Which members look at risk of churning right now, and what's driving it?"*

## Set up by a consultant?

A consultant helping your studio can create the connect link for you and either hand it over or set it up on your behalf — the access is still yours, scoped to your rules. See [Set up on their behalf](/for-consultants/set-up-on-their-behalf).

## Using another assistant?

Claude's custom connector uses the OAuth connect link above. **ChatGPT** can use the same link: open **Plugins** from the sidebar, then the **+** button (or switch the panel to **MCP Servers** → **Add Server**), paste the link as the **Server URL** and set **Authentication** to **OAuth**. The connect page spells out both of ChatGPT's screens. Then **tell ChatGPT to use it** — it won't reach for a new connector on its own. In a new chat, send *"Connect to Kula MCP"*, then *"Run the skill Lets Get Going"*. **Other AI clients** — Cursor, Lovable, the Claude API, custom agents — connect with a **role-scoped access token** instead: you create one in console.kula.digital and it generates the ready-to-paste config for your platform. Same access control, different mechanism. See [Connect — API, Code, Cursor, Lovable](/for-developers/connect).

## Trouble connecting?

* **The Open in Claude button didn't fill anything in** → you may have landed in Claude without being signed in, or on a version that doesn't support pre-filled connectors. Sign in to Claude and click the button again — or just [add it by hand](#add-it-by-hand) with the link shown on the same screen. Both do exactly the same thing.
* **"401" / "invalid token"** → your connect link is expired or has already been used. Ask for a fresh one — or, if it's just expired, for an **Extend** on the one you have.
* **"403" / "scope … cannot …"** → the action needs more access than your role allows. See [Permission levels](/connect-claude-and-access/scopes).
* More in [Troubleshooting](/connect-claude-and-access/troubleshooting).


# Permission levels

How role-based access works — each connect link or access token is issued at a permission level that controls how much personal data the assistant can see.

Access in Kula is controlled by **rules and roles**. When you create a connector in **console.kula.digital**, you set a **permission level** for it. That level travels inside the credential — a [connect link](/connect-claude-and-access/claude) for Claude, or an **access token** for other clients — and controls how much personal information the assistant can ever see.

Redaction happens on our server, so a credential **can't be talked into revealing more than its level allows**. The level is the cap, set by you, per person.

**Rule of thumb: give each person the lowest level that lets them do their job.** You can always create a second connector at a higher level for a specific task.

## The four levels

| Level          | Sees member & staff names? | Sees emails & phone numbers? | Sees payment details? | Recorded on every use? |
| -------------- | -------------------------- | ---------------------------- | --------------------- | ---------------------- |
| **Analytics**  | No (aggregates only)       | No                           | No                    | No                     |
| **Operations** | **Yes**                    | No                           | No                    | No                     |
| **Admin**      | **Yes**                    | **Yes**                      | No                    | No                     |
| **Full**       | **Yes**                    | **Yes**                      | **Yes**               | **Yes — every call**   |

If an action needs a higher level than the credential holds, it returns a clear error (`scope "analytics" cannot call …`) and **nothing leaks** — the assistant simply can't see it.

## Narrow further: hide the books

The four levels control how much **personal** data a credential sees. You can also exclude a whole **category** of data on top of the level — independent of which level you picked.

Today there's one such option: **Hide the books.** Tick it when you create a connector and that credential can never read your **accounting ledger** — the chart of accounts, journals, entries, and invoice lines. Your **commerce** data — sales, payments, and refunds — stays fully visible, so the assistant can still answer revenue and membership questions; it just can't open the general ledger.

It's enforced the same way as the levels: server-side, on every query. A credential set to hide the books gets a clear denial if it tries to read any accounting table, and **nothing leaks**. Use it for, say, a front-desk or operations login that should see who paid and when, but not the studio's books.

> Hide the books is set per connector, like the level, and shown on the credential's card under **Connectors** so you can see at a glance which logins have it. The split between commerce and accounting is the same one in the [schema reference](/for-developers/schema).

## Which level for which role?

```
Does this person's assistant need to see member or staff names?
├── No  → Analytics
└── Yes
    └── Does it need to email, call, or text members (so it needs emails/phones)?
        ├── No  → Operations   ← the sensible default for an owner or coach
        └── Yes
            └── Does it need card details or other sensitive records?
                ├── No  → Admin
                └── Yes → Full  (recorded on every call)
```

* **Analytics** — trends and totals, no individuals. Good for a shared dashboard or a public stat ("our retention was 78% this quarter").
* **Operations** — names visible, contact details hidden. **The everyday default** for an owner or coach asking "who should I check in with?"
* **Admin** — emails and phones visible, so the assistant can draft outreach to specific people. Payment details still hidden.
* **Full** — reveals sensitive records like card fragments. Every use is recorded for your own audit trail. Reserve it for a compliance export or a formal data request — a fire extinguisher, not a light switch.

## Good habits

* **One connector per person or assistant.** Separate connectors give you a separate trail for each, and you can revoke one without breaking the others.
* **Match the level to the role**, not to convenience. Don't hand out Admin "just in case."
* **Treat connect links and tokens like passwords** — don't paste them into a chat or commit them to code.
* **Revoke when someone leaves or changes role.** Revoking a connector in **console.kula.digital** stops its access within about a minute.

## Where to manage access

Everything lives under **Connectors** in **console.kula.digital**: create a connector at the right level, see what each is for, and revoke any of them with one click.

## Related

* [Connect Claude](/connect-claude-and-access/claude) — where the connect link goes.
* [Is my data safe?](/get-started/trust) — the plain-English safety summary.
* [Security & data handling](/trust-and-legal/security) — the technical detail.


# Troubleshooting

Common questions, the errors you might hit and what they mean, current limitations, and how to reach us.

Most problems fall into one of a few buckets. Find yours below. If you're still stuck, [contact us](#getting-help).

## Common messages and what they mean

**"401" or "invalid token"** Your connect link or access token has expired or has already been used. Create a fresh one in **console.kula.digital → Connectors** and reconnect — see [Connect Claude](/connect-claude-and-access/claude). If the link has only *expired* (not been used), you don't need a new one: hit **Extend** on that row and the link already sent keeps working.

**"Open in Claude" opened Claude but nothing was filled in** The button pre-fills Claude's **Add custom connector** dialog. If it opens empty, you were probably signed out of Claude, or you're on a version that doesn't support pre-filled connectors. Sign in and click it again — or add it by hand: the same email and connect page show your connect link in full, right under the button. Both routes create exactly the same connection. See [Connect Claude](/connect-claude-and-access/claude).

**"Couldn't reload tools from the server" (Claude)** This banner usually appears right when you add a connector — Claude checks the tool list *before* you've approved access, gets refused, and then keeps showing that stale message even after the connection succeeds.

* If you haven't connected yet: click **Connect** and approve on the consent screen — see [Connect Claude](/connect-claude-and-access/claude).
* If you already approved: refresh the page, or disconnect and reconnect the connector. Reconnecting from the same Claude account doesn't "use up" your connect link.

If it still appears after a fresh **Connect**, [contact us](#getting-help).

**"403" or "scope … cannot call …"** The task needs a higher **permission level** than your connect link or token has. For example, an Analytics-level credential can't see member names. Create a connector at the right level — see [Permission levels](/connect-claude-and-access/scopes).

**The AI says it can't see any tables, or returns nothing** Usually one of:

* Your first data import is still running. Give it a few minutes and ask again.
* The data source finished importing but the source has a gap. See [Keeping your connectors healthy](/your-data-sources/operating).

**The first answer is slow** The first time you ask a heavy question it does real work. Ask it again and it's quick — recent results are cached for a few minutes.

**A connector shows an error** A provider key may have expired, or the provider had a hiccup. The [Keeping your connectors healthy](/your-data-sources/operating) page walks you through reconnecting and recovering.

## Frequently asked

**Do I have to connect everything to get value?** No. One data source gives you real answers. Add more whenever you like.

**Can the AI change my data or charge a card?** No. It reads from your connected tools and can draft messages, but it never sends money, charges cards, posts to socials, or messages members on its own. See [Is my data safe?](/get-started/trust).

**Can two people use it at once?** Yes. Create a separate connector per person or assistant so each has its own access and trail. See [Permission levels](/connect-claude-and-access/scopes).

**How do I stop it?** Disconnect a data source, or revoke a connector in **console.kula.digital → Connectors**. Access stops within about a minute.

## Current limitations

We'd rather be upfront about what isn't here yet:

* **It reads; it doesn't act in the outside world.** It drafts the email; you send it. By design.
* **Answers are only as complete as your connected data.** If a source isn't connected, or a month is missing from a provider, the AI tells you rather than guessing — but it can't fill a gap it can't see.
* **Each provider has its own quirks and limits** on what we're allowed to read. Those are listed on each connector's page (for example, some Xero data needs a higher Xero plan). See [How connectors work](/your-data-sources/connectors).
* **The first import of a large studio can take 20–30 minutes.** It runs in the background; you don't have to wait on the screen.

## Getting help

**Live chat** is the fastest route: there's a chat bubble in the corner of every page in **console.kula.digital**. You're already signed in, so we can see which studio you're on without you explaining it.

Prefer email? **<support@kula.digital>** — include your studio name and what you were trying to do. If it's about a specific answer, copy the question you asked and what came back; it helps us help you faster.

Found a security issue? Please follow [Responsible disclosure](/trust-and-legal/disclosure) instead.


# How connectors work

What a connector is, the sources you can connect, the simple Test → Connect → Import → Process rhythm they all share, and the gap analysis that tells you your data is complete.

A **connector** brings your studio's data from a tool you already use into Kula Intelligence, so the AI can answer questions about it. You connect each source once; after that it keeps itself up to date.

Each source has its **own setup** (where to find a key, which sign-in to use) and its **own rules** for what we're allowed to read — those live on the per-source pages below. But they all **operate the same way**, and that shared part — gaps, outages, recovery — is on one page: [Keeping your connectors healthy](/your-data-sources/operating).

## What you can connect

| Source                                       | Brings in                                               |
| -------------------------------------------- | ------------------------------------------------------- |
| [Stripe](/your-data-sources/stripe)          | Payments, subscriptions, invoices                       |
| [Wix](/your-data-sources/wix)                | Bookings, members, pricing plans, online store          |
| [Mindbody](/your-data-sources/mindbody)      | Bookings, memberships, classes, sales, clients          |
| [GymMaster](/your-data-sources/gymmaster)    | Members, classes, attendance, memberships               |
| [Xero](/your-data-sources/xero)              | Accounting — contacts, transactions, the general ledger |
| [Meta](/your-data-sources/meta)              | Facebook & Instagram marketing and ad spend             |
| [Google Analytics 4](/your-data-sources/ga4) | Website traffic and where bookings come from            |
| [CSV & ClassPass](/your-data-sources/csv)    | Anything you can export as a spreadsheet                |

You don't need all of them. One source gives you real answers. Add the rest whenever you like.

## The rhythm every connector shares

Connecting a source follows the same four beats:

1. **Test.** You enter your key (or sign in) and we do a cheap check that it works. If it fails here, it's the key, not a broken setup — the message tells you what to fix.
2. **Connect.** We save the connection. Nothing is read yet beyond the test.
3. **Import (backfill).** We pull in your history — we suggest the last 12 months to start. This runs in the background and can take 20–30 minutes for a large studio.
4. **Process.** For most booking and accounting sources, a second step turns the raw history into the tidy, connected data the AI reads (members, sales, attendances). You'll see a **Process** button when it's ready; clicking it is safe to repeat.

> **Why two steps (import, then process)?** We land your data exactly as the provider gave it to us first, untouched, then transform a clean copy. Re-running is always safe, and if a provider changes something we can re-process without re-importing. We keep that raw copy for 30 days (long enough to re-process), then purge it — the tidy data the AI reads stays.

## What happens after that

Your connectors keep themselves current on a schedule — you don't have to re-import. They also **only fill gaps**: re-running an import never overwrites richer data you already have with something thinner.

Once a week, glance at the **Connectors** page on your dashboard to make sure everything's flowing. If something needs attention, the [Keeping your connectors healthy](/your-data-sources/operating) page walks you through it.

## Knowing your data is complete — gap analysis

The worst thing an analytics tool can do is answer confidently from data that's quietly missing a month. So every connector runs **gap analysis** and shows you the result on a **Data coverage** card.

**What a "gap" means.** A gap is a stretch of time where data *should* exist but doesn't — a week of zero attendances at a studio that was open, a month with no sales. That's different from a genuinely quiet day (you were closed). Gaps almost always come from the source, not from Kula: the provider was down, a key expired for a while, or a manual export missed some rows.

**What the card shows.** A calendar-style grid with one small square per day, one row per kind of data (classes, attendance, sales, and so on):

* **Green** squares are days with data — darker the busier the day.
* **Coral, outlined** squares are days that should have data but have none.
* **Grey** squares are days you've told us are legitimately empty (see below).

It's charted by the **real event date** — the actual class and sale dates in your data, not when we imported them — so a missing day is a genuinely missing day. Each row shows a running count ("312 of 365 days") and a **No gaps** / **N days missing** badge, so you can see at a glance whether anything's off and exactly when.

**What you can do about it.** For each missing range the card gives you two buttons:

* **Re-pull** — fetch that one window again. It's safe to repeat and only fills the hole; the rest of your history is untouched.
* **Mark expected** — for a day that was legitimately quiet (a public holiday, before you opened). It stops counting as missing, and the AI knows not to read anything into it. You can mark a single date or a range, including ones that recur every year.

You don't have to babysit this — a nightly sweep closes most gaps on its own. [Keeping your connectors healthy](/your-data-sources/operating) covers the day-to-day detail.

## A note on what we read

Kula Intelligence **reads** from your tools. It never writes back to them, never charges a card, never sends an email or message on your behalf. Each connector page lists exactly what that source lets us read — and what it doesn't.


# Stripe

Connect Stripe to bring in payments, subscriptions, and invoices — read-only.

Stripe brings your **payments, subscriptions, and invoices** into Kula Intelligence, so the AI can answer money questions from your real revenue.

## What we read — and what we don't

* **We read:** customers, charges, subscriptions, invoices, and payment intents — read-only.
* **We don't:** create charges, issue refunds, change subscriptions, or move money. Ever. Full card numbers are never stored; only the last four digits are visible, and only at the **Full** permission level.

## What you'll need

* A **Stripe restricted API key** with read access (created in your Stripe dashboard — you control exactly what it can see).
* A few minutes in your Stripe dashboard to add a webhook (so new payments arrive promptly).

## How to connect

1. In **Stripe → Developers → API keys**, create a **restricted key** with *read* permission for customers, charges, subscriptions, invoices, and payment intents.
2. In Kula, open **Connectors → Stripe**, paste the key, and click **Test**. A green result means the key works.
3. Click **Connect**, then register the **webhook URL** Kula shows you in your Stripe dashboard and run the quick test. This is what lets new payments flow in near-real-time.
4. Start the **import** to pull your payment history (12 months is a good start).

## Good to know

* If you skip the webhook, Stripe still works — Kula just checks for new payments on a schedule instead of instantly.
* Your Stripe key is stored encrypted and only ever used to read. Rotating the key in Stripe? Just paste the new one and re-test.

## If it stops working

A Stripe error almost always means the key was rotated or revoked. Create a fresh restricted key and re-enter it. Full recovery steps are in [Keeping your connectors healthy](/your-data-sources/operating).


# Wix

Connect Wix to bring in bookings, members, pricing plans, and online-store orders — read-only.

Wix brings your **bookings, members, pricing plans, and online-store orders** into Kula Intelligence.

## What we read — and what we don't

* **We read:** contacts and members, bookings and attendance, pricing plans, online-store orders and products, your staff and locations, and basic business info — read-only.
* **We don't:** change your site, your bookings, your plans, or your store.

## What you'll need

* A **Wix API key** with read access to Contacts, Bookings, Pricing Plans, eCommerce, and Business Info (created in the Wix **API Keys Manager**).
* Your **Site ID** (from your Wix dashboard URL).
* Your **Account ID** is optional and only needed for some setups.

## How to connect

1. In the **Wix API Keys Manager**, create a key with *read* access to the areas listed above.
2. In Kula, open **Connectors → Wix**, paste the key and your Site ID, and click **Test**. A failure here means the key or Site ID is wrong — not a broken setup.
3. Click **Connect**, then run the **import** to land your history.
4. When the import finishes, click **Process** to turn it into the tidy members, sales, and attendance the AI reads. A big store can take around 20 minutes to process — it runs in the background.

## Good to know

* Wix exposes different kinds of data through different parts of its API, and they don't all behave the same way. Kula handles each, but if a particular kind of data ever looks unexpectedly thin, let us know so we can check it.
* Re-running the import only fills gaps; **Process** is safe to click again.

## If it stops working

Usually the API key was rotated or its access narrowed. Generate a fresh key with the right read access and re-enter it. See [Keeping your connectors healthy](/your-data-sources/operating).


# Xero

Connect Xero to bring in your accounting — contacts, transactions, and the general ledger — read-only.

Xero brings your **accounting** into Kula Intelligence — contacts, transactions, and (where your plan allows) the general ledger — so money answers reconcile against your actual books.

## What we read — and what we don't

* **We read:** your organisation's settings, contacts, and transactions (invoices, payments, bank transactions) — read-only.
* **We don't:** post entries, change your books, or move money.

## A note on Xero plans

Xero's full **general-ledger (Journals) feed** is only available on Xero's top **Advanced** plan. On standard plans we read what your plan exposes, and you can **upload a general-ledger export as a CSV** to fill in the rest — see [CSV & ClassPass](/your-data-sources/csv). Either way you get a complete picture; the Advanced plan just makes it automatic.

## What you'll need

* A **Xero Custom Connection** (a client ID and secret) that you create in the Xero developer portal, with read-only scopes.
* Someone in your Xero organisation to **approve** the connection.

> A Custom Connection is *client-pays* — it uses a connection slot on your own Xero subscription. That's normal and keeps your accounting access entirely under your control.

## How to connect

1. In the Xero developer portal, create a **Custom Connection** with the read-only scopes for settings, contacts, and transactions.
2. Xero emails an approver in your organisation. Once they approve it, copy the **Client ID** and generate a **Client Secret**.
3. In Kula, open **Connectors → Xero**, paste the Client ID and Secret, and click **Test**. The test confirms which Xero organisation it reached.
4. Click **Connect**, then import. For older history or the general ledger, upload a CSV export as above.

## If it stops working

A Xero error usually means the Custom Connection was revoked or its secret rotated. Recreate or re-approve the connection and re-enter the details. See [Keeping your connectors healthy](/your-data-sources/operating).


# QuickBooks

Connect QuickBooks Online to bring in your accounting — the general ledger, invoices, bills and suppliers — read-only.

QuickBooks Online brings your **accounting** into Kula Intelligence — the general ledger, invoices, bills, expenses, suppliers and your chart of accounts — so money answers reconcile against your actual books.

## What we read — and what we don't

* **We read:** your chart of accounts, invoices, sales receipts, bills, expenses, payments, customers and suppliers — read-only.
* **We don't:** post entries, change your books, or move money. Ever.

## What you'll need

* An **admin login to QuickBooks Online** — someone who can authorise connected apps for the company file.
* About two minutes. There are no keys to create or paste — the connection is a standard **Connect with Intuit** authorisation.

## How to connect

1. In Kula, open **Connectors → QuickBooks** and click **Connect with Intuit**.
2. Sign in to Intuit and, if you have several, pick the **company file** to share.
3. Review the access summary and **authorise** — Intuit sends you straight back to Kula.
4. Kula confirms the company it reached (name, country, currency) and queues the first import.

## What to expect after connecting

* **The first import starts on the next overnight sweep**, not the moment you connect. Kula runs the sweep in your studio's own early hours, so connecting during the day can mean waiting up to about 24 hours. If you don't want to wait, open the connector's monitor page and click **Update to now** — that kicks the first import off immediately.
* The first import pulls about **24 months** of books by default; older history back to the company's start can be loaded on request.
* After that, Kula refreshes your books **nightly**, picking up everything that changed that day — new transactions, edits, voids and deletions.
* The books are only as current as your bookkeeping: Kula sees transactions once they're **posted** in QuickBooks, not while they sit in the bank feed waiting for review.

## Reconnecting

Kula renews the connection's tokens automatically while it's active. If the connection is severed — you revoke Kula's access in Intuit, or the connection sits broken for more than **100 days** — Intuit requires a fresh authorisation. Reconnecting is just the button again: **Connectors → QuickBooks → Reconnect**, sign in, authorise. The import picks up where it left off.

One thing worth knowing if a connection stays broken for a long time: Intuit only tells us about **deletions for 30 days**. Transactions you delete in QuickBooks while Kula is disconnected for longer than that stay in Kula's copy of the books. Reconnect promptly — or tell us, and we'll reload the affected period.

## FAQ

**I run more than one company file.** One Kula org holds **one** QuickBooks connection, so each company file needs its own Kula org. Ask us to set the second one up — reconnecting the same org to a different company file doesn't move the books across, it just leaves the first file's history sitting there un-refreshed.

**QuickBooks Desktop?** Not supported — Desktop has no cloud API to read from, so only QuickBooks **Online** can connect.

**Does Kula see my bank feed or payroll?** No. Bank-feed "For Review" items aren't exposed by Intuit until they're posted to the ledger, and Intuit has no public payroll API — wages appear only as the posted totals in your books.

## If it stops working

A QuickBooks error usually means the authorisation lapsed or was revoked on the Intuit side. Click **Reconnect** and re-authorise. See [Keeping your connectors healthy](/your-data-sources/operating).


# Mindbody

Connect Mindbody to bring in clients, memberships, classes, attendance, and sales — read-only.

![Mindbody](/files/9RwqaiGB7amcS8ClDkvD)

Mindbody brings the core of your studio — **clients, memberships, classes, attendance, and sales** — into Kula Intelligence.

## What we read — and what we don't

* **We read:** your clients and memberships, classes and class visits (attendance), sales, staff, and locations — read-only.
* **We don't:** book, cancel, charge, or change anything in Mindbody.

## What you'll need

* Your Mindbody **Site ID**.
* A **staff username and password** with read access. (Kula holds the Mindbody developer key; you provide your own staff login.)
* You may need to **activate the Kula integration** in your Mindbody account the first time.

## How to connect

1. In Kula, open **Connectors → Mindbody** and enter your Site ID and staff login, then click **Test**.
2. If Mindbody asks you to **activate the integration**, approve it in your Mindbody account and click **Test** again.
3. Click **Connect**, then start the **import**. Your history pulls in the background — the busiest parts (class visits and sales) are imported in chunks, oldest first.
4. When the import finishes, click **Process** to turn it into the tidy members, attendance, and sales the AI reads.

## Good to know

* A busy studio's first import is the slowest part of setup — class attendance especially. It runs in the background and **resumes where it left off** if it's interrupted, so you can close the tab.
* Re-running the import only fills gaps; **Process** is safe to repeat.

## If it stops working

The usual cause is a changed staff password or a deactivated integration. Update the login (or re-activate the integration) and re-test. See [Keeping your connectors healthy](/your-data-sources/operating).


# GymMaster

Connect GymMaster to bring in members, classes, attendance, and memberships — read-only.

GymMaster brings the core of your studio — **members, staff, clubs, classes, attendance, and memberships** — into Kula Intelligence.

## What we read — and what we don't

* **We read:** your members and the staff and clubs they belong to, your class schedule and class attendance, and membership detail — read-only.
* **We don't:** book, cancel, charge, or change anything in GymMaster.

## What you'll need

GymMaster splits access across **two API keys**, so you'll enter both:

* Your GymMaster **base URL** — the web address you log in at, e.g. `https://yourgym.gymmasteronline.com`.
* A **Staff (high-permission) API key** — used to read members, staff, and clubs.
* A **Member (general) API key** — used to read the class schedule, attendance, and per-member detail.
* Optionally, your studio's **timezone** (e.g. `Australia/Sydney`). GymMaster returns class times in local time; setting this keeps them stored correctly. If you leave it blank we use your studio's timezone.

Both keys live under **Settings → Integrations** in GymMaster, and are stored AES-256-encrypted in your own Kula database — you can revoke them in GymMaster at any time.

## How to connect

1. In GymMaster, open **Settings → Integrations** and copy both API keys — the Staff (high-permission) key and the Member (general) key. Note your base URL too.
2. In Kula, open **Connectors → GymMaster**, paste the base URL and both keys (and your timezone if you have it), and click **Test**. We do a quick read against your studio to confirm both keys work — if it fails, the message tells you which key to check.
3. Click **Connect**, then start the **import** to land your history.
4. When the import finishes, click **Process** to turn it into the tidy members, attendance, and memberships the AI reads.

## Good to know

* **Two keys, two jobs.** If the test fails, it names which key didn't work — the high-permission key reads members/staff/clubs, the general key reads classes and attendance. Re-check that one in **Settings → Integrations**.
* **GymMaster rate-limits hard.** Its API is slow to pull from, so a busy studio's first import takes a while and runs in the background, oldest first. It resumes where it left off if interrupted, so you can close the tab.
* **Attendees come through as names.** GymMaster returns class attendance by attendee name rather than a stable member ID, so we match those names back to your members. The occasional name that can't be matched is expected; it doesn't hold up the rest of your data.
* **Deactivating a member in GymMaster loses their history at the source.** GymMaster drops a deactivated member's past detail, so keep members active there until after the import if you can.
* Re-running the import only fills gaps; **Process** is safe to repeat.

## If it stops working

The usual cause is a rotated or revoked API key. Generate a fresh key under **Settings → Integrations**, re-enter it on the connector's page, and re-test. See [Keeping your connectors healthy](/your-data-sources/operating).


# Meta — Facebook & Instagram

Connect Meta to bring in your Facebook and Instagram marketing performance and ad spend — read-only.

Meta brings your **Facebook and Instagram marketing** into Kula Intelligence — page and account performance plus ad spend — so the AI can answer where your members come from and what acquisition really costs.

## What we read — and what we don't

* **We read:** your marketing and advertising performance and spend for the accounts and pages you choose — read-only.
* **We don't:** post, run or change campaigns, or spend money.

## What you'll need

* A **system-user token** generated in **Meta Business Settings**, with read-only scopes.
* The **ad account** and **page** you want to include.

## How to connect

1. In **Meta Business Settings → System users**, create a system user, assign your ad account and page, and generate a **long-lived token** with read-only access.
2. In Kula, open **Connectors → Meta**, paste the token, and click **Test**.
3. Pick which **ad account** and **page** to include, then click **Connect** and import.

## Good to know

* Use a **long-lived system-user token**, not a short personal one — it won't expire on you mid-month.
* Instagram occasionally returns nothing even when it's correctly connected. Kula guards against reading that as a real "zero", but if your Instagram numbers look empty, double-check the Instagram account is linked to the page you selected.

## If it stops working

A Meta error usually means the token lapsed or its permissions changed. Generate a fresh system-user token and re-enter it. See [Keeping your connectors healthy](/your-data-sources/operating).


# Google Analytics 4

Connect GA4 to bring in website traffic, sources, and conversions — aggregated, read-only.

Google Analytics 4 (GA4) brings your **website traffic** into Kula Intelligence — how many people visit, where they come from, which pages perform, and whether visits turn into bookings.

## What we read — and what we don't

* **We read:** aggregated reports — totals and trends — from the GA4 Data API.
* **We don't:** track individual visitors, and we never change your GA4 setup. GA4 only gives us **aggregated** numbers, not personal data.

## This connector is set up differently

There's **no key to paste**. Instead you grant a Google **service-account email** read access to your GA4 property, and tell us your property ID.

## What you'll need

* **Viewer** access on your GA4 property for the service-account email Kula shows you.
* Your GA4 **property ID**.

## How to connect

1. In Kula, open **Connectors → Google Analytics 4** and copy the **service-account email** shown there.
2. In **GA4 → Admin → Property access management**, add that email as a **Viewer**.
3. Back in Kula, enter your **property ID**, click **Test**, then **Connect**.

## Good to know

* GA4 keeps about **14 months** of history, so that's as far back as the import can reach.
* GA4 re-processes the most recent couple of days, so very recent numbers can shift slightly. Kula re-checks a short recent window each day to stay accurate.

## If it stops working

The usual cause is the service-account's Viewer access being removed, or a changed property ID. Re-grant Viewer access (or re-enter the property ID) and re-test. See [Keeping your connectors healthy](/your-data-sources/operating).


# CSV & ClassPass

Upload a spreadsheet — a Mindbody export, a member list, or a ClassPass reservations report — when there's no direct connection.

When a source isn't available as a direct connection — or you just have a spreadsheet — you can **upload a CSV**. This is also how **ClassPass** data comes in.

## What we read — and what we don't

* **We read:** only the file you upload, mapped to the right fields.
* **We don't:** read anything beyond the file you give us.

## What you'll need

* A **CSV file**. Common ones: a Mindbody export, a member or sales list, a Xero general-ledger export, or a **ClassPass reservations report**.

## How to upload

1. In Kula, open **Connectors → CSV & ClassPass** and open the upload workspace.
2. Upload your file. Kula shows you a preview and lets you **map the columns** to the right fields (which column is the member name, the date, the amount, and so on).
3. Confirm the preview and **import**.

## ClassPass

For ClassPass, export the **reservations report** and upload it here.

* ClassPass visits are **matched to your existing members** where possible. Some visitors won't match — a ClassPass guest who isn't one of your members — and that's expected, not an error.
* Each row is identified by its own contents, so **re-uploading the same report doesn't double-count** anything.

## Good to know

* Uploading a corrected file later is safe — rows that are already there aren't duplicated.
* If a column doesn't map cleanly, the preview flags it before anything is imported, so you can fix the file and try again.

## Trouble?

Most CSV issues are a column that didn't map or an unexpected date format — the preview catches these before import. See [Troubleshooting](/connect-claude-and-access/troubleshooting) if something still looks off.


# Keeping your connectors healthy

The one operating guide that applies to every connector — spotting data gaps, recovering when a provider fails, and the weekly glance that keeps everything flowing.

Every connector is set up differently, but they all **run the same way**. So this one page covers operating *all* of them: how to spot a gap, what happens when a provider has a bad day, how to recover, and the one-minute weekly check that keeps everything flowing.

## How a connector keeps itself current

After your first import, each connector tops itself up on a schedule — you don't re-import by hand. Two things make this safe to leave running:

* **Re-running never harms your data.** Imports only **fill gaps**. They never overwrite richer data you already have with something thinner, and the same record is never stored twice.
* **It moves forward only on success.** A connector advances its place in your history *only* when a pull fully succeeds. If a pull half-fails, it simply tries that bit again next time — it doesn't skip ahead and leave a hole.

## Spotting a gap

A **gap** is a stretch of time where data should exist but doesn't — a month with zero attendances at a studio that was open is a gap, not a quiet month. Gaps usually come from the source, not from Kula: a provider was down, a key expired for a while, or a manual export missed some rows.

You'll notice a gap one of three ways:

* The AI tells you. When you ask a question it can't fully answer, it says so and points at the missing period — it won't invent a number.
* A month-by-month work-up flags it. Ask the AI to count your activity month by month and it marks any suspicious gaps — a month with zero attendances at a studio that was open is a data gap, not a quiet month.
* The connector page shows it. Each connector has a **Data coverage** card (below) that draws every day and outlines any with no data.

## Data coverage, day by day

Open any connector and you'll see a **Data coverage** card: a calendar-style grid with one small square per day, one row of squares per kind of data (classes, attendance, sales, and so on). A square is green when that day has data — darker the busier the day — and outlined in coral when a day that should have data has none. Each stream shows a running count ("312 of 365 days") and a **No gaps** / **N days missing** badge, so you can see at a glance whether anything's missing and exactly when.

The grid charts the **real event dates** — the actual class dates and sale dates from your tidy data, not when we imported them — so a missing day is a genuinely missing day, not an artefact of when the import ran. (Coverage appears once you've run **Process**, because it reads the tidy tables.)

For each missing range the card gives you two buttons:

* **Re-pull** — dispatches a fresh, forced pull over *just that window*. It's idempotent and only fills the hole, so it's always safe to click; the rest of your history is untouched. (CSV-style sources like ClassPass have no live source to re-pull from — there you re-import the export covering the window instead.)
* **Mark expected** — for a day that was legitimately quiet (you were closed for a public holiday, the studio hadn't opened yet). This tells Kula the empty day is correct, so it stops counting as missing and the AI knows not to read anything into it. You can mark a one-off date or a range, and tick **recurs every year** for things like Christmas. Marked days show as neutral grey, and you can un-mark one any time.

**To fill a gap:** click **Re-pull** on the missing range (or, for a CSV source, re-import the export covering it). Because pulls only fill gaps, this is always safe — it tops up what's missing and leaves the rest alone. If the day was genuinely quiet, **mark it expected** instead.

> **Behind the scenes.** A re-pull refreshes the raw import *and* re-runs Process over that window, so coverage actually moves — you don't need to click Process again afterwards. A nightly sweep also tops every connector up and re-processes, so most gaps close on their own overnight.

## When a provider has a bad day

Providers (Stripe, Wix, Mindbody, and the rest) occasionally rate-limit us, time out, or go down. Kula is built to ride this out:

* **Temporary hiccups** (the provider is busy or briefly down) are **retried automatically**, backing off and trying again. You usually won't even notice.
* **A genuinely stuck pull** is set aside safely and picked up on the next scheduled run, rather than being lost.
* **If something keeps failing**, the connector flags itself as needing attention and we email you — we don't fail silently.

The most common reason a connector gets stuck is an **expired or revoked key** at the provider's end (someone rotated a Stripe key, a Mindbody password changed, a Meta token lapsed). That shows up as a connection error.

## Recovering a connector

When a connector shows an error, recovery is almost always one of three moves:

1. **Reconnect (most common).** If the key or sign-in expired, generate a fresh one at the provider and re-enter it on the connector's page. See that source's page for exactly where its key lives — for example [Stripe](/your-data-sources/stripe), [Wix](/your-data-sources/wix), [Mindbody](/your-data-sources/mindbody).
2. **Re-pull the affected window.** Use the **Re-pull** button on the missing range in the **Data coverage** card to force a fresh pull over just that period. Safe to repeat — it only fills gaps.
3. **Re-process.** If the import is fine but the tidy data looks off, click **Process** again. Re-processing is idempotent: it fixes what's pending and leaves the rest.

You won't lose anything by trying these in order. None of them overwrite good data.

## Your weekly glance

Once a week, open the **Connectors** page on your dashboard and check each source is green. That's it. If one needs attention, the page tells you what — usually "reconnect" — and the three moves above sort it out.

You can also ask the AI directly:

> *"Are any of my data connectors having problems, and what should I do about it?"*

It can read the recent error summary and tell you in plain English which source needs a look and why.

## The short version

* The **Data coverage** card draws every day and flags gaps; **Re-pull** fills a missing window, **Mark expected** clears a day that was legitimately quiet.
* Gaps come from the source, and a nightly sweep closes most of them on its own.
* Outages are retried for you; persistent ones get flagged and emailed.
* Recovery is almost always **reconnect**, then **re-pull**, then **re-process** — in that order, all safe to repeat.
* A weekly glance at the Connectors page is enough to stay ahead of it.


# What's actually in your data

How each source system maps into Kula Intelligence's canonical model — what we can see, what we can't, and how the systems link to each other.

Every studio's data arrives from a different system — Mindbody, Wix, GymMaster, Stripe, ClassPass — and **no two of them expose the same things**. One gives us door swipes, one gives us none. One gives us every recurring charge, one gives us only the original purchase. One tells us which member attended, one gives us only a name typed on a roster.

Kula Intelligence normalises all of them into a **single canonical model** so a question like "who's at risk this month?" works regardless of which system a studio runs on. But normalising the *shape* can't invent the *substance*. GymMaster has no API for financial data, so no amount of mapping produces revenue — and its door access arrives only by webhook, so nothing exists before the day we connected.

These pages are the honest map of that: one per source system, saying exactly which canonical tables it fills, at what grain, over what history — and, just as importantly, **which tables it leaves empty**.

## Who these pages are for

**AI assistants connected to a studio.** Read the page for whichever sources the studio has connected *before* reasoning about coverage. It will stop you assuming a table has data it can't have, and stop you telling an operator their data is "missing" when the vendor never exposed it in the first place.

Humans are welcome too — engineers maintaining an ingestor, and operators who want to know why one number exists and another doesn't.

## The pages

| Source             | Role in a studio                                | Map                                          |
| ------------------ | ----------------------------------------------- | -------------------------------------------- |
| **GymMaster**      | Booking + membership system of record           | [GymMaster](/whats-in-your-data/gymmaster)   |
| **Mindbody (MBO)** | Booking + membership + POS system of record     | [Mindbody](/whats-in-your-data/mindbody)     |
| **Wix**            | Booking + pricing plans + payments (all-in-one) | [Wix](/whats-in-your-data/wix)               |
| **Stripe**         | Payments + subscriptions                        | [Stripe](/whats-in-your-data/stripe)         |
| **ClassPass**      | Aggregator revenue (CSV export only)            | [ClassPass](/whats-in-your-data/classpass)   |
| **Xero**           | General ledger / accounting                     | [Xero](/whats-in-your-data/xero)             |
| **QuickBooks**     | General ledger / accounting                     | [QuickBooks](/whats-in-your-data/quickbooks) |

One more map covers something that isn't a source at all:

| Layer                      | Role in a studio                                                                 | Map                                             |
| -------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------- |
| **The relationship graph** | Derived: who is connected to whom, how strongly, and who is structurally exposed | [Relationship graph](/whats-in-your-data/graph) |

It is built from the canonical tables the connectors above already fill, so it inherits every one of their gaps — and adds its own. Read it before answering anything about coaches, peer community, resilience, connection quadrants or the attention queue.

Meta, Google Analytics 4 and generic CSV are connected sources too; their ontology maps are the next to be written. Until then, treat their coverage as undocumented rather than assumed — check `get_semantic_catalogue` and the `marketing.*` tables directly.

## Start here, every time

Two calls, in this order, before answering anything about coverage, revenue, attendance, door access or membership history:

1. **`get_system_status`** — which sources this studio has connected, and whether each is current, stale or failing.
2. **`get_doc('data/<source>')`** — the map for each connected source. `data/gymmaster`, `data/mindbody`, `data/wix`, `data/stripe`, `data/classpass`, `data/xero`, `data/quickbooks`. Add `data/graph` for any question about coaches, peer community, resilience, quadrants or the attention queue.

`get_semantic_catalogue` also carries a condensed version of each map's key facts under `source_system_gotchas`, with a pointer back to the full page. The maps are always the fuller answer.

## Prefer the purpose-built tool

Every map ends with a **Recipes** section naming the fastest route to each common question for that source. The general rule holds everywhere: the tools encode rules that ad-hoc SQL gets wrong — pause-awareness, the guarded views, studio-local timezone bucketing, cancelled-session exclusions, identity de-duplication.

| Question                            | Tool                                                                                                   |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| Who's lapsing / at risk             | `list_at_risk_members` (**not** `list_quadrant_members` — quadrants are graph enrichment, not recency) |
| A name or email → canonical id      | `entity_lookup`                                                                                        |
| One member's relationships and risk | `get_member_context`                                                                                   |
| One member's money                  | `get_member_payments`                                                                                  |
| Schedule performance                | `get_class_utilisation`, then `get_time_slot_detail`                                                   |
| One instructor                      | `get_teacher_performance`                                                                              |
| Cohort survival                     | `get_retention_curve`                                                                                  |
| Acquisition cost                    | `get_cac_by_cohort`                                                                                    |
| Is the data current                 | `get_system_status`                                                                                    |
| Anything else                       | `get_semantic_catalogue` → `list_tables` / `get_table_schema` → `execute_query`                        |

When you do write SQL, prefer the `*_guarded` views — they mask restricted columns by the caller's scope and apply source-specific dedup filters that the base tables don't.

And count distinct humans with **`people.member_distinct`**, never `people.member`. The latter is one row *per source system*, so any studio with more than one connector over-counts.

## How to read one of these pages

Each map has the same six sections, in the same order, plus **Recipes** at the end.

**1. What this system is.** Its role, and whether it is the *system of record* for a studio (bookings and memberships live there) or a *satellite* (it knows about money, or marketing, but not the studio's day-to-day).

**2. Coverage at a glance.** A table: vendor entity → canonical table → grain → how far back history goes → how fresh it stays. If a row isn't in that table, we don't have it.

**3. What we cannot get.** The blunt list. Each item says *why* — vendor has no API, endpoint is behind a plan we don't have, the data is destroyed at the source, or it's simply not built yet. **"Not built yet" and "not possible" are very different answers to an operator**, so the list keeps them apart.

**4. Identity and join keys.** How a member/class/payment in this system is identified, and how that id joins to the canonical tables. This is where most wrong answers come from — for example, GymMaster's live attendance feed carries no member id at all.

**5. Counting traps.** Places where the obvious query gives a wrong number: double counts, splits, backfill twins, revenue that looks like a single sale but is 59 charges.

**6. Questions this source can and can't answer.** Concrete phrasing. If you're about to tell an operator "you have no X", check here first — the honest answer is usually "your booking system doesn't expose X" rather than "your data is incomplete".

## The canonical model everything lands in

Whatever the source, data ends up in the same per-studio Postgres schemas. The shared vocabulary:

| Schema       | Holds                                  | Key tables                                                     |
| ------------ | -------------------------------------- | -------------------------------------------------------------- |
| `people`     | Humans and places                      | `member`, `staff`, `location`, `identity_link`                 |
| `commerce`   | Money and what was sold                | `sale`, `payment`, `refund`, `plan`, `product`                 |
| `bookings`   | What happened in the studio            | `class_session`, `attendance`, `appointment`, `facility_entry` |
| `accounting` | The general ledger                     | `account`, `journal`, `entry`                                  |
| `marketing`  | Spend, web, social                     | `campaign`, `spend_daily`, `web_traffic_daily`                 |
| `graph`      | Derived relationships and risk signals | `node`, `edge`, `risk_signal`                                  |
| `ingest`     | The verbatim vendor payloads           | `raw_record`                                                   |

Two distinctions that matter constantly:

* **Sale vs payment.** `commerce.sale` is *what was sold* (the invoice line). `commerce.payment` is *money that moved* (the charge). They are separate axes, joined through `commerce.sale_payment`. A subscription produces one sale per billing period and one payment per successful charge — and either can exist without the other (an unpaid invoice; a charge we can't attribute).
* **Refunds are their own rows.** `commerce.refund` and `commerce.payment_refund`, never negative sales. Net revenue is `SUM(sale.total) − SUM(refund.amount)`.

## Every row carries its lineage

Four columns appear on essentially every ingested row, and they are how you reason about coverage at query time rather than trusting these docs blindly:

| Column               | Means                                                                                                                           |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `source`             | Reverse-DNS system id — `com.gymmaster`, `com.mindbody`, `com.wix`, `com.stripe`, `com.classpass`, `com.xero`, `com.quickbooks` |
| `source_external_id` | The vendor's own id for this thing                                                                                              |
| `source_modified_at` | The vendor's last-modified stamp — freshness, and the last-write-wins guard                                                     |
| `source_is_backfill` | True for rows loaded from a historical import rather than the live feed                                                         |

```sql
-- What sources actually populate this studio, and how fresh is each?
SELECT source, count(*), max(source_modified_at) AS freshest
FROM commerce.sale GROUP BY 1 ORDER BY 2 DESC;
```

Run something like that before asserting a source is missing. A studio may have connected a source last week and only have a week of it.

## How the systems link to each other

A studio running Mindbody for bookings and Stripe for payments has **two `people.member` rows for the same human** — one per source. That is deliberate: each ingestor owns its own rows and never guesses about another's.

The stitching happens in three places:

**`people.identity_link`** maps several source-side members onto one `canonical_member_id`, resolved by email match (high confidence), phone match (medium), or an operator confirming it manually. Ask "all activity for this human" through the canonical id, not through one source's member row.

**`commerce.payment_attribution`** is the read view that does this for money: it resolves the payer to the canonical member and stitches each payment to the sale it funded. Use it instead of grouping `commerce.payment` by `payer_member_id` — gateways mint a fresh customer id per checkout, so the raw grouping over-counts payers and under-counts per-person spend.

**The graph layer** (`graph.node` / `graph.edge`) is enrichment on top of that — affinity, staff concentration, risk signals. It is not a substitute for the canonical tables, and it is rebuilt from them.

One consequence worth internalising: **a studio with a booking system and a separate payment system will not have perfect attribution.** Some payments resolve to a member, some don't. `commerce.payment_attribution.member_id` being NULL is a normal state, not a bug — it means the payer wasn't matchable to a known member.

## When a source has a gap

Say so plainly, and say whose gap it is. The three honest answers, in descending order of usefulness to an operator:

1. **"Your booking system doesn't expose that."** Nothing to fix. Offer the nearest available proxy and be explicit that it's a proxy.
2. **"That's exposed, but we haven't built it yet."** A real roadmap item.
3. **"That should be here and isn't."** A genuine data issue — check `get_system_status` for connector health before concluding this.

What to avoid is the fourth answer, the one these pages exist to prevent: silently assuming the data should be there, computing a number from what little arrived, and presenting it as complete.


# GymMaster — ontology map

What GymMaster gives Kula Intelligence, what it cannot give, and how its members, classes, attendance, memberships and door access map into the canonical model.

**Source id:** `com.gymmaster` · **Role:** booking + membership system of record · **Access:** Member Portal API (two API keys) + a door-access webhook

GymMaster is the studio's operational system: members, clubs, staff, the class schedule, class attendance, memberships, and — from the day the connector goes live — door access. It is **not** a payments system: there is no API for money at all.

Two limits drive most wrong answers here, and both are about *time*:

* **Door access starts the day we connect.** It arrives by webhook, so there is no history before go-live.
* **Class history is available, but attendee names are frozen.** A roster records the member's name as it was on the class date, so a later name change breaks the link permanently.

## Coverage at a glance

| GymMaster entity                | Canonical home                                                         | Grain                          | History                                       | Freshness               |
| ------------------------------- | ---------------------------------------------------------------------- | ------------------------------ | --------------------------------------------- | ----------------------- |
| `companies` (clubs)             | `people.location` (`kind = 'club'`)                                    | one row per club               | full snapshot                                 | each poll               |
| door / area access points       | `people.location` (`kind = 'access_point'`)                            | one row per door               | derived from access data                      | materialised after load |
| `salesrep` (staff)              | `people.staff`                                                         | one row per staff member       | full snapshot                                 | each poll               |
| `members`                       | `people.member`                                                        | one row per member             | full snapshot, modified-since filtered        | each poll               |
| `memberships` (catalogue)       | `commerce.plan`                                                        | one row per membership type    | full snapshot                                 | each poll               |
| `booking/classes/schedule`      | `bookings.class_session`                                               | one row per scheduled class    | **historical — 7-day windows, iterable back** | each poll               |
| per-class `attendees`           | `bookings.attendance`                                                  | one row per attendee per class | matches the class windows pulled              | each poll               |
| door access (webhook)           | `bookings.facility_entry`                                              | one row per access event       | **from go-live only**                         | live                    |
| per-member `member_memberships` | `people.member.plan_name` / `current_plan_id` + a `commerce.plan` stub | current plan only              | current state                                 | each poll               |
| per-member `profile`            | `people.member` (fill-only enrich)                                     | one row per member             | current state                                 | each poll               |
| per-member `visits/daily`       | **lands raw only, not yet mapped**                                     | daily counts                   | —                                             | —                       |

Anything not in that table, we don't have.

### Door access and the location tree

`people.location` holds **two kinds of row** for a GymMaster studio:

* **Clubs** — `kind = 'club'`, straight from the `companies` pull.
* **Doors and areas** — `kind = 'access_point'`, with `parent_location_id` pointing at the club's `source_external_id`.

The access-point rows are **derived from `bookings.facility_entry`**, not pulled from GymMaster: each distinct door seen in the access log becomes a child location under its club (turnstile, 24hr door, hot studio, pilates door, and so on). So door/area detail rolls up to the club while keeping area-level granularity.

Because they're derived, a door only exists as a location once it has appeared in the access log. A door installed but never used won't be there.

Each `bookings.facility_entry` row carries `door_id`, `door_name`, `status` (`granted` / `denied`), `reason`, `entry_method`, `access_category` (`door_access`, `class_attendance`, `sale`, `appointment`) and `access_type` (`entry`, `exit`, `checkin`, `purchase`) — so it is the full member access log, not only door swipes.

## What we cannot get

Each item says *why*. Most of these are hard limits of GymMaster, not gaps in our implementation.

**Member status history before we started capturing it.** GymMaster reports a member's *current* status only — there is no change log, so nothing recovers the moment a member went on hold or lapsed before we were watching. Kula synthesises the change by detecting it itself, so **from the date capture began** every status, membership-type, plan and suspension change is recorded in `people.member_status_event` — read it with `list_member_status_changes`, or the `status_history` block on `get_member_context`. Each member also carries one `is_baseline = true` origin row recording what we first saw; that is an observation, not a change, so filter it out before counting anything. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started FROM people.member_status_event;
```

**Door access before we connected.** GymMaster delivers access records by **webhook only** — there is no API to read history back. We capture every event from the moment the connector goes live, and **nothing before that point exists**. This is a permanent hole, not a backfill waiting to be run.

Always establish the start date before charting anything door-related:

```sql
SELECT min(occurred_at) AS capture_started, max(occurred_at) AS latest,
       count(*)
FROM bookings.facility_entry
WHERE source = 'com.gymmaster';
```

A "walk-ins per month" chart that runs earlier than `capture_started` will show zeros that look like a collapse in traffic. Start the series at the capture date and say so.

**Transactions, payments and revenue.** GymMaster has **no API for financial data**. Not sales, not payments, not invoices, not outstanding balances. `commerce.sale` and `commerce.payment` are **empty** for a GymMaster studio. Revenue questions must be answered from a connected payments source (Stripe) or not at all.

**Appointments and 1:1 sessions.** GymMaster does not do appointments — there is no such concept in the product. `bookings.appointment` is empty, and always will be.

**Anything about inactive or lapsed members beyond their member row.** GymMaster only mints the per-member portal token used for `visits`, `profile` and `member_memberships` for members holding a **current** membership (`Current`, `Active`, `Concession Pack`). Members who are `Expired`, `Hold` or cancelled appear in the member list — you get the person and their status — but we **cannot** read their memberships, their visit history, or their profile detail. So:

* Historical membership timelines don't exist. There is no "which plan were they on in March" for anyone.
* Cancellation reasons, end dates and hold periods aren't available.
* A lapsed member's `plan_name` is their **last known** plan, not evidence they still hold it. Their `status` is the authority on whether they're current.

**A member's history once they're deactivated at the source.** GymMaster drops a deactivated member's detail on its own side. Anything not already imported before deactivation is gone permanently — from GymMaster, and therefore from us.

**Granular per-member visit records.** The per-member `visits/daily` endpoint returns daily **counts**, not individual visits. Those rows land raw but have no canonical home yet (*not built* — the mapping is undecided). Granular presence comes from `bookings.attendance` (class bookings) and `bookings.facility_entry` (access events), not from here.

**Class categories.** GymMaster has no category concept at all. We derive `class_session.class_category` from keywords in the class name (so "Reformer Pilates" buckets as Pilates). It is a **derived** field — a helpful grouping, not vendor truth.

**Bulk pagination.** The members, companies, salesrep and plans lists are snapshots with no offset or page parameter — GymMaster silently ignores an offset and returns the same array forever. Completeness relies on the modified-since window keeping each pull under the vendor's undocumented response cap. The ingestor warns loudly when a pull comes back near that size; a very large single pull is a signal to narrow the window rather than trust the result.

## Class and attendance history — available, with one catch

Unlike door access, **class history is readable**. The schedule endpoint returns a 7-day window per call, and those windows can be iterated backwards, so a studio's past classes and the members who attended them can be loaded well before the connection date.

The catch is how attendance is recorded.

### Attendee names are as-at the class date

GymMaster's per-class attendees endpoint returns **names only** — there is no member id on an attendee — and the name it returns for a historical class is **the name the member had at that time**.

We hash the normalised name into a deterministic placeholder, `unresolved-<hash>`, written to `bookings.attendance.member_id`, with the real name carried in `extras.member_name`. The identity layer reconciles that placeholder to the real `people.member` row when the name matches.

So if a member's name is later changed on their member record — marriage, a correction, a preferred name — **their historical attendance will not link**. The roster still says the old name; the member record says the new one; nothing joins them automatically. The attendance rows are not lost, they just stay unresolved and drop out of any per-member analysis.

Consequences to account for:

* **Joining attendance to members will miss rows.** An attendance row whose `member_id` still starts with `unresolved-` has not been matched. Some proportion of unmatched rows is normal and expected, and it skews **older** — the further back you go, the more name drift has accumulated.
* **Homonyms under-count.** Two distinct members with the same normalised name attending the same class collapse into one attendance row. This is inherent to a names-only feed and is not corrected at ingest.
* **A roster corrected in GymMaster** would otherwise strand the old hash forever. A per-class roster sync deletes attendance rows for a session that are no longer on the vendor's current roster — so attendance for a session reflects the roster as of the last poll, not an append-only log.

Quantify it before quoting per-member attendance figures:

```sql
SELECT count(*) FILTER (WHERE member_id LIKE 'unresolved-%') AS unmatched,
       count(*) AS total
FROM bookings.attendance
WHERE source = 'com.gymmaster';
```

**No-show and late-cancel are not available.** Presence in the attendee list is taken as attended; GymMaster's attendee feed doesn't distinguish booked-but-didn't-show, so `booking_status` is always `attended`.

### Staff names arrive reversed

The class schedule renders the instructor as `"Surname, Firstname"` (often just `"S, Amy"`). We split and normalise it into a natural `First Last` display name. If you see a reversed or single-letter surname on a staff row that came only from the schedule, that's the vendor shape showing through — the direct `salesrep` pull is richer and wins.

## Identity and join keys

| Thing           | GymMaster id                | Canonical                                                      |
| --------------- | --------------------------- | -------------------------------------------------------------- |
| Member          | numeric member id           | `people.member.source_external_id`                             |
| Staff           | `id` or `staffid`           | `people.staff.source_external_id`                              |
| Club            | `id` or `companyid`         | `people.location.source_external_id` (`kind = 'club'`)         |
| Door / area     | door id from the access log | `people.location.source_external_id` (`kind = 'access_point'`) |
| Class session   | booking id                  | `bookings.class_session.source_external_id`                    |
| Membership type | `id` / `membershiptypeid`   | `commerce.plan.source_external_id`                             |
| Class attendee  | **name only** — no id       | `bookings.attendance.member_id` as `unresolved-<hash>`         |

Door access rows carry a real member reference, so **`bookings.facility_entry` joins to members cleanly** — it is the more reliable of the two presence signals where both cover the same period.

## Counting traps

**Two presence signals with different start dates.** `bookings.attendance` goes back as far as the class windows pulled; `bookings.facility_entry` starts at go-live. Any chart combining them will have a step change at the capture date that is an artefact, not a behaviour change. Pick one signal per question, or explicitly window both to the overlap.

**Don't double-count a class visit.** A member attending a booked class can appear in **both** tables — as attendance, and as an access event with `access_category = 'class_attendance'`. Filter facility entries to `access_category = 'door_access'` when you mean "came in without a class booking".

**`plan_name` never gets cleared.** The membership refresh uses COALESCE semantics deliberately: it fills a member's current plan but never blanks it. A lapsed member keeps their last-known plan name. Their `status` field is the authority on whether they're current — not the presence of a plan.

**Class category is derived, and older rows may be NULL.** Sessions ingested before the category rule shipped have `class_category = NULL`. Grouping by category silently drops them.

**Class coverage equals windows run.** A gap in `bookings.class_session` usually means an unrun 7-day window, not a cancelled week — and that gap is also a gap in attendance.

**Read the guarded views.** `bookings.attendance_guarded` and `bookings.class_session_guarded` mask restricted columns by the caller's scope and apply the live-wins filter where a studio also carries rows with `source_is_backfill = true`. Prefer them to the base tables.

## Questions this source can and can't answer

**Can answer well**

* Who are my members, what status are they, which club are they at
* Class schedule, capacity, spots booked, instructor per session — including historically
* Who attended a class (by name, with the matching caveats above)
* Attendance frequency and gaps per member → at-risk / lapsing members
* **Who came in without booking a class** — from the door-access capture date onward
* Traffic by door, by area, by club; denied-access events and their reasons
* Which membership types exist and their prices
* Which plan a **current** member is on right now

**Cannot answer from GymMaster alone**

* Revenue, takings, payments, refunds, outstanding balances *(no API)*
* Door access, walk-ins or access-denied events **before the connector went live** *(webhook-only; no history at the vendor)*
* No-show rate, late-cancel rate *(the attendee list carries no status)*
* 1:1 appointments or PT sessions *(GymMaster doesn't do them)*
* When a member's membership started, ended, or was put on hold *(per-member memberships are current-only, and unreachable for non-current members)*
* Membership history, plan changes, upgrade/downgrade paths *(same)*
* Per-member attendance for anyone whose name changed after the class *(rosters store the name as-at the class date)*
* Anything about a member deactivated in GymMaster before their first import *(destroyed at source)*
* Lifetime value or spend per member *(no money data)*

**Phrase a gap as the vendor's limit and the capture window, not as missing data.** "GymMaster's API doesn't expose door access before we started capturing it, so I can only tell you about door access since we went live on *date*" is correct and useful. "You have no door access data" is misleading, and so is silently starting the chart at zero.

## Recipes — what works well

**Reach for the tool before the SQL.** Each of these encodes rules that hand-written SQL routinely gets wrong on GymMaster data — pause-awareness, the guarded views, the unresolved-name problem, studio-local time.

### Who's lapsing → `list_at_risk_members`

The single highest-value call on a GymMaster studio. It buckets active members by days since last visit (7–13, 14–20, 21–27, 28+), and **a "visit" is an attended class&#x20;*****or*****&#x20;a facility entry** — so once door capture is running, walk-in-only members stop looking lapsed. It also excludes members on a pause, anchors the day count at the pause end for those recently back, and drops drop-in and ClassPass members who aren't expected to return.

Do **not** hand-roll this from `max(occurred_at)` on attendance, and do not use `list_quadrant_members` — quadrants are a relationship-graph enrichment, not a recency measure.

### One member's full picture → `entity_lookup` → `get_member_context`

Resolve the name or email to a canonical id first (`entity_lookup` with `type: member`), then `get_member_context` for connection strength, primary coach and affinity. `get_member_plan_status` gives the raw member record.

Note the GymMaster caveat: if their historical attendance sits under an old name, it won't be on their record. Check for stranded rows before telling an operator a long-standing member has "no history":

```sql
SELECT occurred_at, extras->>'member_name' AS roster_name
FROM bookings.attendance_guarded
WHERE source = 'com.gymmaster'
  AND member_id LIKE 'unresolved-%'
  AND lower(extras->>'member_name') LIKE '%surname%'
ORDER BY occurred_at DESC LIMIT 50;
```

### Schedule performance → `get_class_utilisation`, then `get_time_slot_detail`

`get_class_utilisation` gives the day-of-week × hour heatmap (session count, capacity, booked, mean/min/max fill) over a date range, already excluding cancelled and zero-capacity sessions and bucketing in studio-local time. Then drill into a specific weekday and hour window with `get_time_slot_detail` for the per-class rows.

This is the right answer to "which classes should I cancel" — and it works on GymMaster because it needs only the schedule and bookings, both of which GymMaster gives us well.

### Instructor performance → `get_teacher_performance`

Pass `staff_ref` (a name — resolved server-side) rather than hunting for an id, and set `include_summary: true` so you get the true totals across every matching session rather than just the returned rows.

Watch for the reversed-name artefact: an instructor known only from the class schedule may be stored as `"S, Amy"`. Resolve via `entity_lookup` with `type: staff` if a name doesn't hit.

### Door traffic and walk-ins → SQL on `bookings.facility_entry`

There's no dedicated tool for the access log yet. Always window from the capture start date, and filter the category so a booked class isn't counted as a walk-in:

```sql
-- Walk-ins (door entries with no class booking) by month, since capture began
SELECT date_trunc('month', occurred_at) AS month,
       count(*) AS door_entries,
       count(DISTINCT member_id) AS distinct_members
FROM bookings.facility_entry
WHERE source = 'com.gymmaster'
  AND access_category = 'door_access'
  AND access_type = 'entry'
  AND status = 'granted'
GROUP BY 1 ORDER BY 1;
```

```sql
-- Traffic by door/area, rolled up to its club
SELECT c.name AS club, d.name AS door, count(*) AS entries
FROM bookings.facility_entry f
JOIN people.location d ON d.source = f.source AND d.source_external_id = f.location_id
LEFT JOIN people.location c ON c.source = d.source
                           AND c.source_external_id = d.parent_location_id
WHERE f.source = 'com.gymmaster' AND f.access_category = 'door_access'
GROUP BY 1, 2 ORDER BY 3 DESC;
```

Denied entries are their own signal — `status = 'denied'` with a `reason` often surfaces expired memberships before the member notices.

### Is the data current → `get_system_status`

Before saying anything is missing. It reports each connected source as current / stale / failing with a plain-language "current as of" date.

### Counting real members → `people.member_distinct`

`people.member` is one row per source system. On a GymMaster-only studio that's harmless, but the moment Stripe is also connected the same human has two rows. Count `people.member_distinct`.

### What doesn't work on GymMaster

Don't reach for `get_member_payments`, revenue queries on `commerce.sale`, `get_cac_by_cohort` (needs marketing spend), or `get_retention_curve` (needs recurring charges) — all of them depend on financial data GymMaster has no API for. If the studio also runs Stripe, those tools work off the Stripe side; on GymMaster alone they'll return empty and that is the correct result, not a fault.

## Where this lives in the code

| Concern                                              | Path                                                                                                                      |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Entity catalogue, API keys, collection modes         | `services/ingestors/gymmaster/internal/gmclient/specs.go`                                                                 |
| Pull + per-member walk + current-member gating       | `services/ingestors/gymmaster/internal/gmclient/collect.go`                                                               |
| Fan-out transforms (classes, attendees, memberships) | `services/ingestors/gymmaster/internal/transform/`                                                                        |
| 1:1 SQL projections (members, staff, clubs, plans)   | `services/intelligence/internal/ingest/project/templates_gymmaster.go`                                                    |
| Access-log schema (doors, categories, outcomes)      | `services/intelligence/migrations/postgres/org/300_bookings/009_facility_entry_door.sql`, `011_facility_entry_access.sql` |
| Door/area locations as club children                 | `services/intelligence/migrations/postgres/org/100_people/009_location_door_model.sql`                                    |
| Operator-facing connect guide                        | [GymMaster connector](/your-data-sources/gymmaster)                                                                       |


# Mindbody — ontology map

What Mindbody gives Kula Intelligence, what it cannot give, and how its clients, classes, visits, sales and contracts map into the canonical model.

**Source ids:** `com.mindbody` (operational) and `com.mindbody.billing` (sales) · **Role:** booking + membership + point-of-sale system of record · **Access:** Public API v6, per-site

Mindbody is the most complete single source we ingest for a studio: it knows the schedule, who attended, what they bought, and on what terms. Where it falls down is *membership structure* — there is no memberships endpoint, so a member's plan has to be inferred.

Note the two source ids. Operational entities land under `com.mindbody`; sales land under `com.mindbody.billing`. A query filtering `source = 'com.mindbody'` on `commerce.sale` returns nothing.

## Coverage at a glance

| MBO entity           | Canonical home                                            | Grain                                   | History                                                                          | Freshness |
| -------------------- | --------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | --------- |
| `/site/locations`    | `people.location`                                         | one per site                            | full                                                                             | each poll |
| `/staff/staff`       | `people.staff`                                            | one per staff member                    | **\~12 months from the direct pull** (older instructors backfilled from classes) | each poll |
| `/sale/services`     | `commerce.plan`                                           | one per pricing option                  | full                                                                             | each poll |
| `/site/categories`   | `bookings.category`                                       | one per class/service category          | full                                                                             | each poll |
| `/sale/contracts`    | `commerce.plan`                                           | one per contract item                   | full, per location                                                               | each poll |
| `/sale/products`     | `commerce.product`                                        | one per retail product                  | full                                                                             | each poll |
| `/client/clients`    | `people.member`                                           | one per client                          | full                                                                             | each poll |
| `/sale/sales`        | `commerce.sale` (+ `commerce.payment`)                    | **one row per sale line**, not per sale | date-windowed                                                                    | each poll |
| `/class/classes`     | `bookings.class_session` (+ staff/location stubs)         | one per scheduled class                 | date-windowed                                                                    | each poll |
| `/class/classvisits` | `bookings.attendance` (+ plan stubs, member plan refresh) | one per visit                           | date-windowed, fetched per class                                                 | each poll |

## What we cannot get

**Memberships as a vendor enrolment record.** MBO v6 has **no `/sale/memberships` endpoint** — it 404s on live sites. What a studio calls a "membership" is represented two ways, and we pull both: `services` (sellable pricing options) and `contracts` (recurring agreements). Both map into `commerce.plan`. There is no vendor object that says "this member holds this membership from this date to this date", so:

* **Membership start/end dates, freezes and holds are not available.** There is no enrolment record to read them from.
* **A member who never attends has no plan on file.** Nothing to infer from.

What we *do* have instead is better than it sounds — see below.

**A trustworthy member status&#x20;*****from MBO*****.** MBO does not reliably tell us when a member lapses. We work around it by deriving the status from behaviour nightly — see [below](#mbos-member-status-is-not-trustworthy) — so `people.member.status` *is* dependable; MBO's own flag is not, and nothing recovers the moment a member actually stopped attending.

**Status history before we started capturing it.** MBO exposes only the member's *current* status — there is no change log and no effective-dated history, so "when did she go inactive?" is unanswerable for anything that happened before Kula began synthesising the event itself. From that date forward it *is* answerable: every status, membership-type, plan and suspension change is captured in `people.member_status_event` (`list_member_status_changes`, or the `status_history` block on `get_member_context`). Each member also carries one `is_baseline = true` origin row recording what we first saw — that is an observation, not a change, so filter it out before counting. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started
FROM people.member_status_event;
```

**Appointments / 1:1 sessions.** Not supported. `bookings.appointment` is empty for MBO studios — nothing about PT sessions, 1:1 bookings or appointment revenue can be answered from this source.

**Door access / facility entry.** No MBO endpoint. `bookings.facility_entry` is empty.

**An "all visits" feed.** There is no endpoint that lists visits directly. Visits are reached by listing classes in a window, then calling `classvisits` per class id. Consequence: **a visit is only ever ingested if its class was ingested first.** A gap in the class window is also a gap in attendance, and attendance for a period cannot be back-filled without re-pulling that period's classes.

**Currency.** MBO carries no currency on sales — each site is single-currency. We stamp a configured default (`MBO_DEFAULT_CURRENCY`, AUD on the AU cell). If no currency is configured the ingestor **skips payment emission entirely** rather than write an invalid row, so `commerce.payment` can legitimately be empty even where `commerce.sale` is full.

**Timezone on schedule times.** MBO returns naive datetimes with no zone. They are interpreted using the studio's configured IANA timezone. A studio with a missing or wrong timezone setting will have class times shifted.

## Identity and join keys

| Thing     | MBO id                            | Canonical                                   |
| --------- | --------------------------------- | ------------------------------------------- |
| Client    | `Id` (also `UniqueId`)            | `people.member.source_external_id`          |
| Staff     | `Id`                              | `people.staff.source_external_id`           |
| Location  | `Id`                              | `people.location.source_external_id`        |
| Class     | `Id`                              | `bookings.class_session.source_external_id` |
| Visit     | `Id`, with `ClassId` + `ClientId` | `bookings.attendance`                       |
| Sale line | `{Sale.Id}:{SaleDetailId}`        | `commerce.sale.source_external_id`          |

**MBO attendance carries a real client id**, so attendance joins cleanly to `people.member` — no name matching is involved. Visits also carry a rich embedded `Service` object, which is where the plan attribution on a visit comes from — keyed on `Service.ProductId`, not `Service.Id` (the latter is a per-purchase instance and would explode the plan table).

## MBO's member status is not trustworthy

`people.member.status` comes straight from MBO's `Active` / `IsProspect` flags. **MBO does not reliably update it when a member lapses**, and re-checking every member against the MBO API is too expensive to do at studio scale. So the field says what MBO last claimed, not what is true.

How wrong it gets: one live MBO org had **2,748 members marked `active` whose most recent class was in 2025 or earlier**, 808 of them not seen since 2024. Roughly half that member base was carrying a status that said otherwise — and, because the plan is stamped from the last visit and never cleared, a plan name to match.

**Never treat `status = 'active'` as "currently a member" on MBO without checking behaviour.** Any count of active members, members-on-plan, or revenue-per-active-member reads high — often by a factor approaching two.

### Derive it from activity instead

The reliable signal is data we already hold: a class visit (`bookings.attendance`) or a payment (`commerce.sale` under `com.mindbody.billing`). A member with neither in the last 30 days has effectively lapsed, whatever MBO says.

```sql
-- Members MBO calls active, ranked by how long they've actually been gone
SELECT m.source_external_id, m.plan_name,
       (SELECT max(a.occurred_at) FROM bookings.attendance_guarded a
         WHERE a.source = 'com.mindbody'
           AND a.member_id = m.source_external_id)          AS last_visit,
       (SELECT max(s.occurred_at) FROM commerce.sale_guarded s
         WHERE s.source = 'com.mindbody.billing'
           AND s.member_id = m.source_external_id)          AS last_sale
FROM people.member m
WHERE m.source = 'com.mindbody' AND m.status = 'active'
ORDER BY last_visit NULLS FIRST
LIMIT 100;
```

Two exclusions matter when you apply this. A member with `is_booking_suspended` is on a **deliberate hold**, not lapsed. And a member whose `member_since` is inside the window simply hasn't had time to attend yet.

### We correct it automatically, every night

Kula does not leave `status` as MBO reports it. A nightly job re-derives it for every MBO org:

> **A member marked `active` is switched to `inactive` when they have had no class visit AND no payment for 30 days.**

Both conditions must hold — a member who is still being billed stays active even if they haven't attended, and a member who attends stays active even if nothing has been charged in the window.

It also runs in reverse. **The moment real activity reappears — a visit or a payment inside the window — the member is switched back to `active`** and the field is handed back to MBO. A member who takes two months off and returns is corrected in both directions without anyone intervening.

With one important qualification: **only a member MBO still calls `active` comes back as `active`.** If MBO has said something real in the meantime — `cancelled`, `suspended`, `prospect` — that verdict already won (the correction only ever suppresses a *stale* `active`), and handing the field back leaves MBO's value untouched. Someone who cancels their membership and then buys a single drop-in class stays `cancelled`; they are not resurrected as an active member by the visit.

Two deliberate exclusions stop it doing harm:

* **A member on a hold (`is_booking_suspended`) is never swept.** A deliberate pause is not a lapse.
* **A member who joined inside the window is never swept.** Someone who signed up three weeks ago and hasn't booked yet is new, not lapsed.

**This override outranks MBO.** The derived value is stored in `people.member.status_override` — a column the ingest projections do not own — and the MBO clients projection consults it, so a restate can no longer overwrite a derived `inactive` with MBO's stale `active`. Earlier versions of this correction were undone by the next nightly restate; that is fixed.

A vendor value *other* than `active` still wins immediately. If MBO reports a member cancelled or suspended, that is positive evidence of a real change and it takes effect regardless of the override.

### Not every "active" member is a member — some are just enquiries

After the correction runs, a cohort remains marked `active` with **no plan, no attendance and no payment**. These are not a data fault and not lapsed members: they are **people who registered interest and never converted** — a walk-in who gave their details, a web enquiry, someone who created an account and never booked.

They stay `active` on purpose. The rule never sweeps a member whose `member_since` falls inside the window, because someone who joined three weeks ago and hasn't booked yet is new, not lapsed. Once they age past the window with still no activity, the next nightly run sweeps them like anyone else.

MBO doesn't help distinguish them: its `IsProspect` flag is `false` on these records and `Active` is `true`, so they arrive looking exactly like paying members. The combination of *active + no plan + no activity* is what identifies them.

```sql
-- Registered interest, never converted
SELECT count(*) FILTER (WHERE member_since >= now() - interval '30 days') AS still_in_grace,
       count(*) FILTER (WHERE member_since <  now() - interval '30 days') AS older_unconverted,
       count(*)                                                           AS total
FROM people.member m
WHERE m.source = 'com.mindbody'
  AND m.status = 'active'
  AND m.current_plan_id IS NULL
  AND NOT EXISTS (SELECT 1 FROM bookings.attendance a
                   WHERE a.source = m.source AND a.member_id = m.source_external_id)
  AND NOT EXISTS (SELECT 1 FROM commerce.sale s
                   WHERE s.member_id = m.source_external_id);
```

**Count them separately from members.** Folding them into an active-member number overstates the business; calling them churn overstates the problem. They are a lead list — and a useful one, because a studio that accumulates hundreds of unconverted enquiries has a conversion problem worth naming. `still_in_grace` is this month's crop; `older_unconverted` should be near zero once the nightly rule has been running, since it sweeps them on age.

### Reading it

`status` is the corrected value, so ordinary queries and every tool (`list_at_risk_members` included) get the truthful answer with no special handling. When you need to know *which* answer you're looking at:

```sql
SELECT status,                 -- the value in force
       status_override,        -- non-null ⇒ derived, outranking MBO
       status_override_rule,   -- which rule asserted it
       status_override_at      -- when it last did
FROM people.member
WHERE source = 'com.mindbody' AND source_external_id = $1;
```

`status_override IS NULL` means you are seeing MBO's own claim. MBO's raw client payload is always retained in `source_extras` if you need to compare.

**The rule refuses to run on untrustworthy data.** Reading "no activity" as "lapsed" is only valid while attendance is actually arriving — if the MBO connector broke, the same logic would mark a healthy studio's entire member base inactive 30 days later. So the job checks first that attendance is current (something within 7 days) and deeper than the window, and **skips the org entirely** otherwise. If a studio's statuses look stale, check `get_system_status` — a skipped org is usually a broken connector, not a broken rule.

Three things worth saying out loud when you report on this:

* **The 30-day window is a judgement call, not a fact.** If a studio defines lapsed differently, say which window produced your numbers.
* **A corrected count will be lower than MBO's own dashboard**, sometimes dramatically. That's the point — but an operator comparing the two deserves to be told why they differ rather than left to assume one is broken.

### The most valuable question this raises

An "active" member who hasn't attended in months but **is still being billed** is live revenue at acute churn risk. That's a very different finding from a stale record nobody cleaned up, and the two are trivial to tell apart:

```sql
-- Of the long-absent "active" members, how many are still paying?
SELECT count(*) FILTER (WHERE EXISTS (
         SELECT 1 FROM commerce.sale_guarded s
          WHERE s.source = 'com.mindbody.billing'
            AND s.member_id = m.source_external_id
            AND s.occurred_at >= now() - interval '90 days')) AS still_billed,
       count(*)                                               AS long_absent_active
FROM people.member m
WHERE m.source = 'com.mindbody' AND m.status = 'active'
  AND NOT EXISTS (SELECT 1 FROM bookings.attendance a
                   WHERE a.source = 'com.mindbody'
                     AND a.member_id = m.source_external_id
                     AND a.occurred_at >= now() - interval '180 days');
```

Note that `list_at_risk_members` caps its look-back at 365 days, so members absent longer than that fall outside it entirely. For the long tail, this SQL is the right instrument.

## Every visit records the plan it was drawn against

This is MBO's compensation for having no enrolment record, and it is worth more than a static membership field.

Each visit names the pricing option the member consumed, and that lands in **two** places:

* **`bookings.attendance.plan_source_external_id`** — a loose FK to `commerce.plan`, on every single attendance row.
* **`people.member.current_plan_id` / `plan_name`** — refreshed from the member's most recent visit, applied behind a `plan_as_of` watermark so out-of-order restates converge on their latest *visit* rather than the latest vendor edit.

So two things are answerable that a plain membership field could not answer:

1. **A member's current plan**, kept live as they attend.
2. **Their plan history** — because the plan is stamped per visit, you can see exactly when someone moved from an intro pack to a membership, or upgraded mid-cycle. Their *effective* plan timeline is reconstructable from attendance even though MBO exposes no enrolment dates.

Two caveats to state when you use it:

* It is the plan **as at each visit**, not a vendor-asserted enrolment. A member who stopped attending carries the plan they last attended on — read `status` for whether they're current.
* `plan_source_external_id` is **mutable**. Operators reclassify which plan covered a class when someone upgrades mid-cycle or a wrong assignment is corrected, so a snapshot of this column can change retroactively. Read `bookings.attendance.updated_at` if you need to detect that.

## Counting traps

**Sales are per line, not per sale.** One MBO sale with three purchased items becomes three `commerce.sale` rows. Counting rows counts line items. Revenue is correct because each line carries its own total; **transaction count is not** — count `DISTINCT` on the sale id portion of `source_external_id`.

**A contract sale can split across rows.** Reconcile via `source` / `source_external_id` rather than assuming one row per agreement. A sale line with `item_type = 'contract'` should be looked up in `commerce.plan` for its full terms.

**`classpass` does not mean ClassPass.** On MBO, `commerce.plan.category = 'classpass'` — and the `membership_type` derived from it — means the pricing option has a **session count**: a 10-pack, a 20-pack, a class pass. Unlimited options get `'membership'`. It says nothing about the ClassPass aggregator.

The two are easy to confuse and the mistake is expensive. One live org shows 7,898 `classpass` against 1,592 `membership` — that is a studio whose members mostly buy packs, **not** a studio overrun by an aggregator. Real aggregator revenue is identified by the [ClassPass export](/whats-in-your-data/classpass) and by sale provenance, never by this field.

It also has a quiet consequence: `list_at_risk_members` filters `membership_type IN ('membership','intro')`, so pack members are excluded from the at-risk board by design. In a pack-heavy studio that removes most of the member base — worth saying explicitly rather than presenting a short list as the whole picture.

**Aggregator flows arrive here.** ClassPass and MBO-Online bookings land as ordinary MBO sales and visits. They should be tagged and reported separately from headline studio revenue — a ClassPass visit is worth a fraction of a direct booking, and the true payout only appears if the studio also uploads their [ClassPass export](/whats-in-your-data/classpass).

**Attendance keeps multiple rows per (member, session) on purpose.** The lifecycle — booked, cancelled, re-booked, attended — is signal we want. Fix double counting at the **read** layer by taking the latest status, never by de-duplicating at ingest.

**Instructors older than \~12 months come from class records, not the staff pull.** Every class embeds its full instructor object, so we emit fill-only staff rows from classes to backfill them. Those rows are sparser than the direct pull. Don't read a thin staff row as "this instructor left".

**`SignedIn: true` means attended.** MBO's `AppointmentStatus` field is unreliable for class visits; the visit's own signed-in/no-show flags are the truth. This is already handled at transform, but matters if you're reading `ingest.raw_record` directly.

## Questions this source can and can't answer

**Can answer well**

* Full class schedule, capacity, utilisation, instructor per class
* Who attended, who no-showed, who late-cancelled — with real member ids
* Revenue by line item, by product, by pricing option, by date
* Retail vs service revenue split
* What pricing options and contracts exist, at what price and interval
* Retention and at-risk analysis on real attendance history
* **Which plan a member is currently on**, and **when they changed plans** — from the plan stamped on each visit
* **Who is genuinely still a member** — `status` is corrected nightly from activity rather than trusting MBO's flag
* **Who registered interest and never converted** — active, no plan, no activity; a lead list, not a member count

**Cannot answer from MBO alone**

* **The moment a member actually lapsed** *(MBO never reports it; our nightly rule detects it 30 days after their last visit or payment, so the date is a detection date, not a cancellation date)*
* The *contractual* start or end date of a membership *(no enrolment record — you can see when they started attending on a plan, which is not the same thing)*
* Membership freeze/hold periods *(same)*
* A plan for a member who has never attended *(nothing to infer from)*
* 1:1 appointments and PT sessions *(not supported)*
* Door access / walk-ins *(no API)*
* Current plan for a member who hasn't attended recently *(inferred from last visit)*
* True ClassPass payout *(MBO records the booking; the payout is in the ClassPass export)*

## Recipes — what works well

MBO is the most tool-friendly source we have: real member ids on attendance, real money, real categories. Almost every purpose-built tool works properly here.

### Who's lapsing → `list_at_risk_members`

Buckets active members by days since last visit, pause-aware, excluding drop-in and ClassPass members. On MBO this is high quality because attendance carries a real client id. Don't hand-roll it, and don't use `list_quadrant_members` — quadrants are graph enrichment, not recency.

### Schedule performance → `get_class_utilisation` → `get_time_slot_detail`

The day-of-week × hour heatmap, then the per-class drill-down for a chosen weekday and hour window. Both read the guarded views, exclude cancelled and zero-capacity sessions, and bucket in studio-local time — which matters on MBO, whose datetimes are naive and interpreted with the studio timezone.

### Instructor performance → `get_teacher_performance`

Pass `staff_ref` (name) and `include_summary: true`. Set `include_subs` when you want sessions where they assisted, not just led.

Remember instructors older than \~12 months arrive as sparse rows backfilled from class records — a thin staff row is not evidence someone left.

### Revenue → SQL on `commerce.sale`, minding the grain and the source

Sales land under `com.mindbody.billing`, **not** `com.mindbody`, and one row is one **line**, not one sale:

```sql
-- Revenue and true transaction count by month
SELECT date_trunc('month', occurred_at) AS month,
       sum(total) AS revenue,
       count(*) AS line_items,
       count(DISTINCT split_part(source_external_id, ':', 1)) AS transactions
FROM commerce.sale_guarded
WHERE source = 'com.mindbody.billing'
GROUP BY 1 ORDER BY 1;
```

Net revenue subtracts `commerce.refund` — refunds are their own rows, never negative sales.

### Split aggregator revenue out

ClassPass and MBO-Online flows arrive as ordinary MBO sales. Report them separately from headline membership revenue, and if the studio also uploads their [ClassPass export](/whats-in-your-data/classpass), the true payout is there rather than in MBO.

### A member's plan history → SQL on `bookings.attendance`

The plan is stamped per visit, so plan changes are a `DISTINCT ON` away. This is the closest thing MBO gives to a membership timeline:

```sql
-- When did this member change plans? One row per plan spell.
WITH spells AS (
  SELECT a.occurred_at,
         a.plan_source_external_id AS plan_id,
         lag(a.plan_source_external_id) OVER (ORDER BY a.occurred_at)
           AS prev_plan_id
  FROM bookings.attendance_guarded a
  WHERE a.source = 'com.mindbody'
    AND a.member_id = $1
    AND NULLIF(a.plan_source_external_id, '') IS NOT NULL
)
SELECT s.occurred_at AS changed_at, p.name AS moved_to
FROM spells s
LEFT JOIN commerce.plan p
  ON p.source = 'com.mindbody' AND p.source_external_id = s.plan_id
WHERE s.prev_plan_id IS DISTINCT FROM s.plan_id
ORDER BY s.occurred_at;
```

```sql
-- Plan mix across the member base, from the live plan on each member
SELECT COALESCE(p.name, m.current_plan_id, '(none on file)') AS plan,
       count(*) AS members
FROM people.member m
LEFT JOIN commerce.plan p
  ON p.source = 'com.mindbody' AND p.source_external_id = m.current_plan_id
WHERE m.source = 'com.mindbody'
GROUP BY 1 ORDER BY 2 DESC;
```

Say "the plan they attended on" rather than "their membership" — it's an effective plan, not a contractual one, and members who stopped attending carry their last one.

### One member's money → `get_member_payments`

Combines `commerce.sale` with any standalone gateway payments not linked to a sale, with a `record_kind` column discriminating the two. Better than querying either table alone.

### Retention → `get_retention_curve`

Cohort × period survival for members on a recurring membership. Bucket by `signup_month` (default), `plan`, or `location`. Class-packs, drop-ins, intros and comps are excluded by design — on MBO that exclusion is meaningful, because pricing options and contracts are mixed in `commerce.plan`.

### Acquisition cost → `get_cac_by_cohort`

Works if the studio also has Meta or GA4 connected for spend. The cohort denominator is members whose **first attended class** was in that month — which MBO supports properly.

### Is the data current → `get_system_status`

Reports each connected source as current / stale / failing with a plain-language "current as of" date. Check it before concluding anything is missing.

### What doesn't work on MBO

`bookings.appointment` and `bookings.facility_entry` are empty — appointments aren't supported, and there's no access API. Membership start/end/hold dates don't exist, so anything phrased as "when did their membership begin" has to be answered from their first sale or first visit instead, with that substitution stated.

## Where this lives in the code

| Concern                                                             | Path                                                             |
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
| Entity catalogue, endpoints, paging modes                           | `services/ingestors/mbo/internal/mboclient/specs.go`             |
| Fan-out transforms (classes, visits, sales, contracts)              | `services/ingestors/mbo/internal/transform/`                     |
| 1:1 SQL projections (clients, staff, locations, products, services) | `services/intelligence/internal/ingest/project/templates_mbo.go` |
| Operator-facing connect guide                                       | [Mindbody connector](/your-data-sources/mindbody)                |


# Wix — ontology map

What Wix gives Kula Intelligence, what it cannot give, and how its contacts, sessions, participations, pricing plans and Cashier transactions map into the canonical model.

**Source id:** `com.wix` · **Role:** all-in-one — website, bookings, pricing plans and payments · **Access:** Wix REST APIs, API-key auth, per-site

## Scope: Wix Bookings, not all of Wix

Read this first — it prevents the most common confusion on this source.

**Wix is a platform, not a product.** A studio's Wix account can run a website, an online store, blogs, events, forms, email marketing and more. **We ingest the Bookings side only** — the schedule, the people, the plans, and the money that flows through Cashier. Everything else the studio does on Wix is outside what Kula sees.

This matters because Wix's own interface and API show the operator *all* of it. So an operator looking at their Wix dashboard — or anyone querying Wix directly — sees totals that will not match ours, and neither number is wrong: they are counting different things. Store revenue, site traffic and non-bookings activity are theirs, not ours.

When a Wix figure doesn't reconcile, **check the scope before assuming a sync problem.** Say "that includes your Wix store, which Kula doesn't ingest" rather than treating the difference as missing data.

## What makes Wix awkward

Almost nothing is denormalised. A session carries a schedule id and a resource list; a participation carries an event id and a contact id. Every human-meaningful field — class name, category, instructor, class time, the plan that covered the visit — is resolved by joining across other entities at transform time.

That means Wix data quality depends on **which entities were pulled together**. A session ingested without its services cache gets a schedule id where a class name should be.

## Coverage at a glance

| Wix entity                   | Canonical home                                | Grain                            | Notes                                          |
| ---------------------------- | --------------------------------------------- | -------------------------------- | ---------------------------------------------- |
| `locations`                  | `people.location`                             | one per location                 | single page, no paging                         |
| `staff` (staff-members)      | `people.staff`                                | one per staff member             | `resourceId` retained for session joins        |
| `resources`                  | **no canonical table**                        | —                                | cache only: resolves session → instructor      |
| `services`                   | **no canonical table**                        | —                                | cache only: class name + category per schedule |
| `categories`                 | `bookings.category`                           | one per category                 | `kind = service`                               |
| `pricing_plans`              | `commerce.plan`                               | one per plan                     |                                                |
| `products` (Stores)          | `commerce.product`                            | one per product                  | offset-paged                                   |
| `contacts`                   | `people.member`                               | one per contact                  | offset-paged — see traps                       |
| `pricing_orders`             | `commerce.plan` stub + member plan-state stub | one per membership/pass purchase | **not a revenue source**                       |
| `ecom_orders`                | **landed raw, not mapped**                    | one per class booking            | the booking + cancellation record — see below  |
| `transactions` (Cashier)     | `commerce.sale` + `commerce.refund`           | one per settled charge           | **the canonical revenue source**               |
| `sessions` (calendar events) | `bookings.class_session`                      | one per session                  | date-windowed                                  |
| `participations`             | `bookings.attendance`                         | one per participation            |                                                |

## The booking lifecycle — check-ins, cancellations, late cancels

Wix records more of the booking lifecycle than a first read suggests.

**`CONFIRMED` means the member checked in to the class.** It is a record of attendance, not merely of a confirmed reservation — so `bookings.attendance.booking_status = 'attended'` on a Wix row reflects a real check-in.

**A booking is an order, and it can be cancelled before the class.** When a member books a class an order is created; cancelling it before the class start produces a cancellation with its own timestamp. That lands on `bookings.attendance.cancelled_at`, alongside the session's `starts_at`.

**Late cancel is derivable — cancelled within 2 hours of class start.** The canonical `latecancel` status is **not** written at ingest: Wix cancellations land as `cancelled`, and the two-hour rule is applied at **read** time from `cancelled_at` versus the session's `starts_at`. The [Recipes](#recipes--what-works-well) section has the query. Treat a stored `booking_status = 'cancelled'` as "cancelled at some point", and derive the late-cancel split yourself.

**No-show behaviour is derivable from the orders, but it's a heavy call.** The ecom orders — the booking records — carry what's needed to separate someone who booked and didn't show from someone who checked in. Those rows land raw in `ingest.raw_record` and are **not mapped canonically**, so answering it means reading raw orders rather than querying a table. It is possible; it is expensive. Say so before committing to it, and prefer the `CONFIRMED` check-in signal on `bookings.attendance` where that suffices.

## What we cannot get

**Anything outside Wix Bookings.** Store orders, site analytics, blog, events, forms — a studio may run all of them on Wix and none of it reaches Kula. See [Scope](#scope-wix-bookings-not-all-of-wix).

**Door access / facility entry.** No concept in Wix. `bookings.facility_entry` is empty.

**Member status history before we started capturing it.** Wix reports a pricing order's *current* state only, and the historical and superseded orders behind it are collapsed to one snapshot at ingest — so there is no lapse, pause or cancellation timeline to read back. Kula synthesises the change by detecting it itself, so **from the date capture began** every status, membership-type, plan and suspension change is recorded in `people.member_status_event` — read it with `list_member_status_changes`, or the `status_history` block on `get_member_context`. Each member also carries one `is_baseline = true` origin row recording what we first saw; that is an observation, not a change, so filter it out before counting anything. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started FROM people.member_status_event;
```

**Accounting / general ledger.** Wix Cashier gives settled payments, not a chart of accounts. `accounting.*` requires Xero or QuickBooks.

**A single revenue endpoint that covers everything historically.** Cashier transactions are date-windowed, so revenue history is only as deep as the windows that have been pulled. Older revenue that predates the pull window is simply absent — the pricing-orders endpoint cannot substitute (see the next section).

**Most list endpoints are not date-filterable.** Only calendar sessions and Cashier transactions honour a `[from, to]` window. Everything else re-pulls in full, which means a "just fetch what changed since Tuesday" question has no answer for contacts, plans, staff or products.

**Appointments as a distinct concept.** Wix bookings all land as calendar sessions; there is no separate 1:1 appointment feed mapped. `bookings.appointment` is empty for Wix.

## The revenue story — read this before quoting Wix revenue

Wix has **two order systems and a payments system**, and they are not interchangeable.

* **Pricing orders** are membership/pass *purchases*. An order records the purchase, **not the stream of recurring charges against it**. A weekly membership billed 59 times appears as **one order**. Using orders as revenue under-counts recurring income massively — historically it reported roughly a quarter of true revenue.
* **eCommerce orders** are checkout records. They overlap Cashier and are landed raw but map to nothing.
* **Cashier transactions** are every settled payment — recurring cycles and one-off purchases alike. **This is the canonical revenue source**, and the only one that maps to `commerce.sale`.

So: `commerce.sale` for a Wix studio comes from Cashier transactions. Pricing orders survive only as plan and member-plan-state stubs that keep the commerce graph linked. If a revenue figure looks implausibly low, check whether the transactions entity has actually been pulled for the period in question.

Refunds ride on the parent SALE transaction's `refunds[]` array and become `commerce.refund` rows. Standalone REFUND and CHARGEBACK transaction rows are **skipped** so each gateway refund is counted exactly once — don't add them back in.

## Identity and join keys

| Thing         | Wix id                             | Canonical                                   |
| ------------- | ---------------------------------- | ------------------------------------------- |
| Contact       | `id` / `_id`                       | `people.member.source_external_id`          |
| Staff         | `id`, plus `resourceId`            | `people.staff.source_external_id`           |
| Session       | `id`                               | `bookings.class_session.source_external_id` |
| Participation | `id`, with `eventId` + `contactId` | `bookings.attendance`                       |
| Transaction   | `transactionId` (**not** `id`)     | `commerce.sale.source_external_id`          |
| Pricing plan  | `id`                               | `commerce.plan.source_external_id`          |

The cross-links that matter:

* **Session → class name and category** goes `session.scheduleId` → service → category. Without the services pull, a session's `class_template_id` falls back to the raw schedule id and the class name is missing.
* **Session → instructor** goes `resources[0].id` → staff via `staff.resourceId`. Wix convention is that the first resource is the instructor; further mapped resources become secondary staff.
* **Transaction → member and plan** rides inline on the transaction (`order.description.items[]` and `wixAppBuyerId`), with `is_recurring` and the membership category resolved from the pricing-orders cache.

## Counting traps

**Wix totals include products Kula doesn't ingest.** The most frequent "discrepancy" on this source isn't a data problem at all — it's a Wix dashboard number that spans the store or the site alongside bookings. Check the scope before investigating a sync.

**Order-based revenue under-counts.** Restated from above because it is the single most expensive mistake available on Wix data. Sales come from Cashier transactions; a pricing order is one purchase, not its stream of recurring charges.

**`cancelled` is not the same as late cancel.** Wix cancellations all land as `booking_status = 'cancelled'`. The late-cancel split is a read-time derivation from `cancelled_at` against the session's `starts_at` — see the Recipes. Reporting all cancellations as late cancels overstates the problem substantially.

**Pricing-plan events can arrive slightly out of order.** Order by `occurred_at`, not by ingest order, when reconstructing plan state.

## Questions this source can and can't answer

**Can answer well**

* Class schedule, capacity, instructor, category, room
* Who was booked on which session, who **checked in** (`CONFIRMED`), and who cancelled
* **Late-cancel rate** — derived at read time from the cancellation timestamp against class start
* True recurring revenue per member and per plan (from transactions)
* Membership/pass plans, prices, intervals
* Refund totals and net revenue
* Retention and at-risk analysis, on booking history

**Answerable, but expensive**

* Full no-show behaviour — derivable from the raw ecom orders, which land unmapped. Flag the cost before starting; the check-in signal on `bookings.attendance` covers most questions more cheaply.

**Cannot answer from Wix alone**

* Anything outside Wix Bookings — store, site, blog, events *(out of scope)*
* Walk-ins / door access *(no concept)*
* General-ledger reporting, P\&L, expenses *(needs Xero/QuickBooks)*
* Revenue before the earliest pulled transaction window
* "What changed since yesterday" for contacts, plans, staff or products *(endpoints aren't date-filterable)*

## Recipes — what works well

### Who's lapsing → `list_at_risk_members`

Pause-aware, bucketed by days since last visit. On Wix a "visit" means an attended class — there's no door data — and because `CONFIRMED` records a real check-in, the recency signal is genuine attendance rather than a booking that may never have been honoured.

### Schedule performance → `get_class_utilisation` → `get_time_slot_detail`

Wix is strong here: sessions resolve to a real class name, category, instructor and room through the cross-entity caches. The heatmap first, then the per-class drill-down for a chosen weekday and hour.

If class names come back looking like opaque ids, the services entity hasn't been pulled — that's a connector problem worth flagging rather than a naming quirk.

### Instructor performance → `get_teacher_performance`

Pass `staff_ref` and `include_summary: true`. Wix resolves the instructor via the session's first resource, so an instructor who only ever appears as a secondary resource needs `include_subs: true`.

### Revenue → SQL on Cashier transactions

This is the recipe that most often goes wrong. Sales come from transactions; pricing orders are **not** revenue.

```sql
-- Recurring vs one-off revenue by month
SELECT date_trunc('month', occurred_at) AS month,
       sum(total) FILTER (WHERE is_recurring) AS recurring,
       sum(total) FILTER (WHERE NOT is_recurring) AS one_off,
       sum(total) AS gross
FROM commerce.sale_guarded
WHERE source = 'com.wix'
GROUP BY 1 ORDER BY 1;
```

Net of refunds subtracts `commerce.refund` — and remember standalone REFUND and CHARGEBACK transaction rows are deliberately skipped, so don't add them back.

Before quoting any revenue figure, confirm the transactions entity actually covers the period. An implausibly low month is usually an unpulled window, not a bad month:

```sql
SELECT min(occurred_at), max(occurred_at), count(*)
FROM commerce.sale WHERE source = 'com.wix';
```

### Retention → `get_retention_curve`

Works well on Wix, because Cashier transactions give real per-cycle recurring charges — which is exactly what the curve measures (billing continuity). This is the tool that would have been wrong under the old order-based revenue model.

### One member's money → `get_member_payments`

Combines sales with unlinked gateway payments, with a `record_kind` column discriminating them.

### Late cancels → SQL on `cancelled_at` vs class start

Not stored — derived. A cancellation inside 2 hours of the class start is a late cancel:

```sql
-- Late-cancel rate by month
WITH c AS (
  SELECT a.occurred_at,
         a.cancelled_at,
         s.starts_at,
         a.cancelled_at > s.starts_at - interval '2 hours' AS is_late
  FROM bookings.attendance_guarded a
  JOIN bookings.class_session_guarded s
    ON s.source = a.source
   AND s.source_external_id = a.class_session_id
  WHERE a.source = 'com.wix'
    AND a.booking_status = 'cancelled'
    AND a.cancelled_at IS NOT NULL
)
SELECT date_trunc('month', starts_at) AS month,
       count(*)                        AS cancellations,
       count(*) FILTER (WHERE is_late) AS late_cancels,
       round(100.0 * count(*) FILTER (WHERE is_late) / nullif(count(*),0), 1)
         AS late_pct
FROM c
GROUP BY 1 ORDER BY 1;
```

Swap `date_trunc` for `a.member_id` to find the members who repeatedly late-cancel — usually a more actionable list than the aggregate.

Two hours is the studio-agnostic default. If the operator runs a different late-cancel window, use theirs and say which you applied.

### Attendance and check-in rate → `bookings.attendance_guarded`

`booking_status = 'attended'` means the member checked in, so an attendance rate is a straightforward count — no inference caveat needed. Splitting booked-but-never-honoured out fully needs the raw ecom orders, which is the expensive path; flag the cost before starting it.

### What doesn't work on Wix

Door access and walk-ins (no concept in Wix), general-ledger reporting (needs Xero or QuickBooks), and anything about the studio's Wix store, site or other Wix products — out of scope, not missing. `get_cac_by_cohort` needs a marketing source connected for spend.

## Where this lives in the code

| Concern                                    | Path                                                        |
| ------------------------------------------ | ----------------------------------------------------------- |
| Entity catalogue, endpoints, paging shapes | `services/ingestors/wix/internal/wixclient/specs.go`        |
| Transforms + the cross-entity caches       | `services/ingestors/wix/internal/transform/`                |
| Revenue (Cashier transactions)             | `services/ingestors/wix/internal/transform/transactions.go` |
| Sessions + participations                  | `services/ingestors/wix/internal/transform/bookings.go`     |
| Operator-facing connect guide              | [Wix connector](/your-data-sources/wix)                     |


# Stripe — ontology map

What Stripe gives Kula Intelligence, what it cannot give, and how its customers, subscriptions, invoices and charges map onto the sale and payment axes.

**Source id:** `com.stripe` · **Role:** payments and subscriptions — a **satellite** source, not a system of record for the studio floor · **Access:** Stripe REST API, restricted key

Stripe knows about money. It knows nothing about classes, attendance, instructors, or what a member actually did in the studio. A Stripe-only studio has revenue and no operations; a studio with Stripe *and* a booking system has both, joined imperfectly through identity resolution.

The single most important thing to understand about Stripe here is the **two-axis model**.

## The two axes: sale vs payment

| Axis      | Stripe object | Canonical table    | What it means                                                             |
| --------- | ------------- | ------------------ | ------------------------------------------------------------------------- |
| **Sale**  | `invoice`     | `commerce.sale`    | What was sold, and on what terms — authoritative for subscription billing |
| **Money** | `charge`      | `commerce.payment` | Money that actually moved, with fee, net and payout                       |

They are joined through `commerce.sale_payment`, and the invoice's expanded payments are what make that link. **Every** charge becomes a payment, including invoice-backed ones — the invoice owns the sale, the charge owns the money.

Practical consequence: a subscription that bills monthly produces one `commerce.sale` per period and one `commerce.payment` per successful charge. Summing sales and summing payments answer different questions (*billed* vs *collected*), and neither is wrong.

## Coverage at a glance

| Stripe entity   | Canonical home                                 | Grain                     | Window                  |
| --------------- | ---------------------------------------------- | ------------------------- | ----------------------- |
| `products`      | `commerce.product`                             | one per product           | full pull               |
| `prices`        | `commerce.plan`                                | one per price             | full pull               |
| `customers`     | `people.member`                                | one per customer          | full pull               |
| `subscriptions` | `people.member` plan-state stub                | winning subscription only | full pull, `status=all` |
| `invoices`      | `commerce.sale`                                | one per invoice           | **date-windowed**       |
| `charges`       | `commerce.payment` + `commerce.payment_refund` | one per charge / refund   | **date-windowed**       |

`prices` map to `commerce.plan` because Stripe's product+price pair is what the rest of the model calls a plan. Recurring prices carry the billing interval; one-off prices don't.

Subscriptions are pulled with `status=all` deliberately — plan-state correctness needs old-but-active and cancelled subscriptions, which Stripe's default filter hides. Only the customer's **winning** subscription produces a member plan-state stub, keyed on the *customer* id so it merges onto the same `people.member` row.

Charges are expanded to include their balance transaction (fee, net, payout) and each refund's balance transaction (fee returned). That's four levels of expansion — Stripe's maximum.

## What we cannot get

**Anything operational.** No classes, no attendance, no instructors, no schedule, no door access. `bookings.*` is entirely empty for a Stripe-only studio. This is not a limitation to work around — it's what Stripe is.

**Member status history before we started capturing it.** Stripe reports a subscription's *current* status. The cancellation timestamps it does send (`canceled_at`, `cancel_at_period_end`, `current_period_end`) land untyped in `source_extras` and describe one subscription, not the person's history. Kula synthesises the change by detecting it itself, so **from the date capture began** every status, membership-type, plan and suspension change is recorded in `people.member_status_event` — read it with `list_member_status_changes`, or the `status_history` block on `get_member_context`. Two Stripe-specific traps: a subscription set to cancel at period end produces **no** event until the status actually flips, and Stripe's duplicate customer rows each carry their own events, so roll up on `COALESCE(canonical_member_id, id)` before counting people. Each member also carries one `is_baseline = true` origin row recording what we first saw; that is an observation, not a change. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started FROM people.member_status_event;
```

**Real member identity.** A Stripe customer is a payer, not a studio member. The name is a free-text field split into first/last on a best-effort basis, and email is whatever was entered at checkout. Where a studio also runs a booking system, the Stripe `people.member` row is a **duplicate** of the real member and must be joined through `people.identity_link`.

**Revenue before the earliest pulled window.** Invoices and charges are date-windowed. History goes back as far as the backfill has run, no further.

**Disputes and chargebacks**, **payouts as a first-class object**, and **Stripe Billing schedule detail** (upcoming invoices, proration previews) are not mapped. Fee and net *are* available per payment, from the expanded balance transaction.

**Webhook-driven live updates** are not the primary path — the ingestor polls. A studio's Stripe data is as fresh as the last sweep, not real-time.

## Identity and join keys

| Thing    | Stripe id | Canonical                             |
| -------- | --------- | ------------------------------------- |
| Customer | `cus_…`   | `people.member.source_external_id`    |
| Product  | `prod_…`  | `commerce.product.source_external_id` |
| Price    | `price_…` | `commerce.plan.source_external_id`    |
| Invoice  | `in_…`    | `commerce.sale.source_external_id`    |
| Charge   | `ch_…`    | `commerce.payment.source_external_id` |

An invoice line's `item_id` is the **price id**, which is what joins a sale to `commerce.plan`. `is_recurring` on the sale distinguishes subscription billing from one-off purchases.

`provider_transaction_id` on a payment is the Stripe `pi_…` / `ch_…` id. It is PII level 3 and masked in the guarded views — read `commerce.payment_guarded`, not the base table, unless you specifically need the gateway id.

### Attributing payments to real people

Do **not** group `commerce.payment` by `payer_member_id` to get per-member spend. Stripe mints a customer id per checkout flow in many setups, so the same human appears as several payers and the total splits across them.

Use **`commerce.payment_attribution`** instead. It resolves the payer to the canonical, identity-linked member and stitches each payment to the sale it funded. Two rules when querying it:

* `payment_amount` **repeats** on every row of a split payment (one row per funded sale). Never `SUM` it directly — sum `allocated_amount`, or sum over `DISTINCT payment_id`.
* `member_id` being NULL is normal — it means the payer couldn't be matched to a known member. Report unattributed revenue as its own bucket rather than dropping it.

## Counting traps

**Deleted customers don't vanish.** A `customer.deleted` snapshot maps to `status = 'inactive'` with `stripe_deleted` in extras. They still count in a naive member count.

**Failed and uncollectible invoices are still sales.** A `commerce.sale` with no matching payment usually means an invoice that was billed but never paid. That's a real dunning signal, not a data gap.

**One invoice, many charges.** Retries after a failed payment produce multiple charges against one invoice. Counting charges over-counts transactions; counting successful charges is what you usually want.

**Refunds are separate rows.** `commerce.payment_refund` for the money side, `commerce.refund` for the sale side. Net revenue subtracts them; it does not net them into the sale row.

**Currency is real here.** Unlike MBO, Stripe carries currency per charge. Multi-currency studios will have mixed-currency rows — don't sum across currencies.

## Questions this source can and can't answer

**Can answer well**

* Revenue billed vs revenue collected, by period
* MRR, active subscriptions, churned subscriptions
* Fees, net revenue, payout amounts
* Failed payments and dunning exposure
* Refund totals and rate
* Per-member spend — **through `commerce.payment_attribution`**

**Cannot answer from Stripe alone**

* Anything about attendance, classes or instructors *(not in Stripe)*
* Whether a paying customer is actually still coming to the studio
* Who a payer is, beyond the name and email typed at checkout
* Revenue before the backfill window
* Disputes and chargebacks *(not mapped)*

**When both Stripe and a booking system are connected**, the interesting questions live in the join: paying-but-not-attending members, attendance without a matching payment, plan price versus what's actually collected. Those all route through `people.identity_link` and `commerce.payment_attribution`, and all of them carry an unmatched remainder that should be stated rather than hidden.

## Recipes — what works well

### One member's money → `get_member_payments`

The right first call. It combines `commerce.sale` (invoices) with the member's standalone gateway payments not yet linked to a sale, so cash that never produced a sale-side row still shows, with a `record_kind` column discriminating them. Scope rules apply — billing email needs admin, card last-4 needs full scope.

### Per-member spend across gateway duplicates → `commerce.payment_attribution`

Never group `commerce.payment` by `payer_member_id`. Use the view, and mind that `payment_amount` repeats per funded sale:

```sql
-- Lifetime collected, de-duped across gateway customer ids
SELECT sum(payment_amount) FROM (
  SELECT DISTINCT payment_id, payment_amount
  FROM commerce.payment_attribution
  WHERE member_id = $1 AND status = 'succeeded'
) t;
```

```sql
-- How much revenue can't be attributed to a known member?
SELECT count(*) FILTER (WHERE member_id IS NULL) AS unattributed,
       count(*) AS total
FROM commerce.payment_attribution;
```

Report that denominator alongside any per-member revenue analysis.

### Billed vs collected → keep the two axes apart

```sql
SELECT date_trunc('month', s.occurred_at) AS month,
       sum(s.total) AS billed
FROM commerce.sale_guarded s
WHERE s.source = 'com.stripe' GROUP BY 1 ORDER BY 1;
```

```sql
SELECT date_trunc('month', occurred_at) AS month,
       sum(amount) FILTER (WHERE status = 'succeeded') AS collected,
       sum(fee)    FILTER (WHERE status = 'succeeded') AS fees,
       sum(net)    FILTER (WHERE status = 'succeeded') AS net
FROM commerce.payment_guarded
WHERE source = 'com.stripe' GROUP BY 1 ORDER BY 1;
```

The gap between them is dunning exposure, not a discrepancy. A sale with no matching payment is a real signal worth surfacing.

### Retention → `get_retention_curve`

Stripe is the ideal input: each cohort is the month of a member's first successful recurring charge, and retention is billing continuity. Bucket by `signup_month`, `plan`, or `location`.

### Acquisition cost → `get_cac_by_cohort`

Needs a marketing source for spend. Note the cohort denominator is members whose first **attended class** was in that month — so on a Stripe-only org there's no denominator. This tool needs a booking source alongside Stripe.

### Counting real members → `people.member_distinct`

Critical here. `people.member` is one row per source, and Stripe adds a row per customer id. Counting `people.member` on a studio running Stripe plus a booking system materially over-counts.

### Is the data current → `get_system_status`

Stripe is polled, not webhook-driven, so "as of" matters.

### What doesn't work on Stripe alone

Anything operational — `list_at_risk_members`, `get_class_utilisation`, `get_time_slot_detail`, `get_teacher_performance` all need booking data Stripe doesn't have. On a Stripe-only org they return empty, and that is the correct result.

### The interesting questions are in the join

With Stripe *and* a booking system connected, the valuable analyses are cross-source: paying-but-not-attending members, attendance with no matching payment, plan price versus what's actually collected. All route through `people.identity_link` and `commerce.payment_attribution` — and all carry an unmatched remainder that should be stated, not hidden.

## Where this lives in the code

| Concern                                           | Path                                                                                     |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Entity catalogue and expansions                   | `services/ingestors/stripe/internal/stripeclient/stripeclient.go`                        |
| Transforms (customers, prices, invoices, charges) | `services/ingestors/stripe/internal/transform/`                                          |
| Payment attribution view                          | `services/intelligence/migrations/postgres/org/200_commerce/008_payment_attribution.sql` |
| Operator-facing connect guide                     | [Stripe connector](/your-data-sources/stripe)                                            |


# ClassPass — ontology map

What a ClassPass reservation-report export gives Kula Intelligence, what it cannot give, and how payouts link back to attendance recorded in the booking system.

**Source id:** `com.classpass` · **Role:** aggregator revenue only · **Access:** operator-uploaded CSV — **there is no ClassPass API for studios**

ClassPass is unlike every other source here. It has no studio API and no connector service. What a studio has is a **reservation report** they download from ClassPass and upload to Kula, and what that report carries is **the payout side only**.

The attendance itself is not missing — ClassPass bookings flow into the studio's booking system (Mindbody, Wix, GymMaster), so `bookings.attendance` already has the visit. What was missing was the money: how much ClassPass actually paid the studio for it. That is the entire job of this source.

## Coverage at a glance

The export must carry these 12 columns. ClassPass ships more than one shape of this report and has added columns over time, so columns are matched **by name**: extra columns are carried into the raw record untouched, and any column order works. A file MISSING one of the twelve is rejected whole-file, naming what was absent — that means "not a ClassPass reservation report", not a mapping to solve.

Because the idempotency key is built from the twelve named columns only, the same reservation exported in the narrow and the wider shape hashes to the same reference and dedupes against itself. Importing both variants of an overlapping period does not double-count the payout.

```
venue, location, class_name, class_date, start_time, uid,
first_name, last_name, earnings, payout_reason, status, instructor_name
```

| CSV column                                           | Canonical home                                             |
| ---------------------------------------------------- | ---------------------------------------------------------- |
| `earnings`                                           | `commerce.sale.amount` / `.total`                          |
| `class_date` + `start_time`                          | `commerce.sale.occurred_at` (studio-local)                 |
| `first_name` + `last_name`                           | used for matching; not written as a member                 |
| `uid`                                                | ClassPass member id, retained in `source_extras`           |
| `status`                                             | `source_extras.status` — also drives the payout kind       |
| `payout_reason`                                      | `source_extras` (e.g. "Reactivate past members (25% Off)") |
| `class_name`, `instructor_name`, `venue`, `location` | `source_extras`, and used for matching                     |

**One `commerce.sale` per CSV row.** No members are created, no attendance is created, no plans, no products. `class_date` is `DD/MM/YYYY` and `start_time` is 24-hour local.

## What we cannot get

**Anything ClassPass didn't put in the export.** There is no API, so the CSV is the complete universe of ClassPass data. Concretely, that means:

* **No ClassPass member profile.** `uid` is stable, but there's no email, phone, join date or history. ClassPass attendees are **not** created as `people.member` rows — they exist as members only if the booking system also recorded them.
* **No ClassPass-side booking or cancellation timeline.** Only the final `status` per reservation.
* **No forward view.** The report is historical; there's nothing to poll, so there is no "ClassPass bookings for next week".
* **No refunds or adjustments as their own rows.** Corrections appear as further reservation rows in a later export.

**Freshness is manual.** There is no recurring sync. The data is as current as the last file an operator uploaded — which is why a ClassPass revenue figure should always be qualified by the period the uploads actually cover.

**Coverage is whatever was uploaded.** Gaps between exports are invisible. A missing month looks like a month with no ClassPass revenue.

## How the payout links back to attendance

Stage 2 runs set-based in SQL and matches each CSV row to an existing attendance record:

1. **Find the member by normalised name.** Two keys, matching on either: *name\_full* (lower-cased, diacritics folded, punctuation stripped, first+last concatenated — so `José` matches `Jose` and `O'Leary` matches `OLeary`), and *name\_sort* (same fold, but tokens sorted — so `Zhang, Wei` matches `Wei Zhang`).
2. **Find that member's attendance within ±1 day** of the class time. The slack absorbs timezone skew between the CSV's studio-local times and the booking system's timestamps.
3. **Pick among candidates** by exact class-name-and-time, then exact time, then class name, then closest time.

**The sale is created either way.** An unmatched row still becomes revenue, flagged `source_extras.match_status = 'unmatched'`. The attendance link is fill-only — an attendance row already pointing at a sale (paid through the booking system) is never overwritten.

There is a **rematch** path: unmatched sales are re-run against current member and attendance data and promoted when they now match. So a studio that uploads ClassPass before finishing their booking-system import will see match rates improve after a rematch rather than being stuck.

Idempotency is `sha256(uid | class_date | start_time | class_name | status)`. Overlapping exports collide per-row and count as duplicates. `status` is part of the key on purpose — a "Late Cancel" and a "Late Cancel Rebooked" for the same slot are genuinely different payouts.

## Counting traps

**Match rate is a real, reportable number.** Query `source_extras->>'match_status'` and say what proportion of ClassPass revenue could be attributed to a known member. Reporting per-member ClassPass spend without that denominator overstates confidence.

**No booking data means no matches at all.** If the studio's booking system hasn't been imported yet, every row lands unmatched. That's expected — say so rather than reporting it as a data quality problem.

**Cancellation-fee payouts are mixed in.** Rows whose `status` contains "cancel" are cancellation-fee payouts, not attended classes. Splitting attended-class revenue from cancellation fees matters for any per-visit economics.

**ClassPass revenue is not studio revenue at the same rate.** A ClassPass visit typically pays a fraction of a direct booking, and `payout_reason` shows why (promotions, reactivation discounts). Keep it reported separately from headline membership revenue — the canonical glossary treats aggregator flows as an excluded bucket for exactly this reason.

**The booking system also recorded these visits.** ClassPass attendance exists in `bookings.attendance` with `source = 'com.mindbody'` (or Wix, GymMaster). Don't count the ClassPass sale as an extra visit — it's the money for a visit already counted.

## Questions this source can and can't answer

**Can answer well**

* What ClassPass actually paid, per reservation and in total, for the uploaded periods
* Effective revenue per ClassPass visit, and how it compares to a direct booking
* Which promotions and payout reasons are driving the rate
* Which instructors and class times attract the most aggregator volume
* Cancellation-fee income

**Cannot answer**

* Who ClassPass attendees are, beyond a name and an opaque uid
* Whether a ClassPass attendee converted to a direct member *(only inferable via a name match to the booking system)*
* Anything about periods not covered by an upload
* Forward bookings
* ClassPass-side cancellations, waitlists or member behaviour

## Importing an export from the chat screen

An operator does not have to open the console to load a ClassPass export. Three tools cover the whole cycle:

| Tool                          | What it does                                                                                                     |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `import_classpass_export`     | Takes the CSV as text, lands every row, creates the sales and links them to attendance — both stages in one call |
| `rematch_classpass_sales`     | Re-evaluates sales still flagged unmatched, after more members or booking history arrive                         |
| `get_classpass_import_status` | What is already loaded, what is unmatched and why, and the per-day reconciliation against the booking roster     |

**Pass the file through verbatim.** `csv_text` must be a byte-for-byte copy of the export: the header line and every data row, in the original order. Do not reformat, requote, sample, collapse similar rows or stop early — each row is a separate payout, so an omitted row is revenue the studio never sees. Set `expected_rows` to the number of data rows you believe the file has; the tool compares it against what arrived and returns `row_count_warning` when they disagree, which is how an incomplete paste gets caught. If that warning comes back, say so plainly and re-import the full file rather than reporting the import as done.

The ceiling is **512 KB of CSV** (roughly 4,500 reservations — comfortably a month, plausibly a quarter). Above that the tool refuses and names the console upload, which has no practical limit; that is the right path for a multi-year backfill, and it is not a failure to say so.

Re-importing is safe. Rows dedupe on a content hash of `uid | class_date | start_time | class_name | status`, so an overlapping export adds only what is genuinely new. Note that `earnings` is deliberately **not** part of that hash: a corrected re-export updates nothing and double-counts nothing.

After the import, read `unmatched_breakdown` before characterising the result. `late_cancel` and `privacy_erased` are expected and need no action — see [Counting traps](#counting-traps). Only `unlinked` is worth chasing, and `rematch_classpass_sales` is the thing to run once more members or more booking history have loaded.

## Recipes — what works well

For reading the data back there is no ClassPass-specific tool — everything here is SQL over `commerce.sale` filtered to `source = 'com.classpass'`. Three things are worth doing every time.

### Always establish coverage and match rate first

```sql
SELECT min(occurred_at) AS earliest, max(occurred_at) AS latest,
       count(*) AS reservations,
       count(*) FILTER (WHERE source_extras->>'match_status' = 'matched') AS matched
FROM commerce.sale
WHERE source = 'com.classpass';
```

Two numbers to quote alongside any ClassPass figure: the period the uploads actually cover, and the proportion matched to a known member. A gap between exports looks identical to a month with no ClassPass revenue.

### Effective revenue per visit, and how it compares

```sql
SELECT date_trunc('month', occurred_at) AS month,
       count(*) AS reservations,
       sum(total) AS payout,
       round(avg(total), 2) AS avg_per_visit
FROM commerce.sale
WHERE source = 'com.classpass'
  AND coalesce(source_extras->>'status','') NOT ILIKE '%cancel%'
GROUP BY 1 ORDER BY 1;
```

The `NOT ILIKE '%cancel%'` matters — cancellation-fee payouts are mixed in and are not attended classes. Run the inverse to report them separately.

### What's driving the rate

```sql
SELECT coalesce(nullif(source_extras->>'payout_reason',''), '(none)') AS reason,
       count(*), round(avg(total), 2) AS avg_payout
FROM commerce.sale
WHERE source = 'com.classpass'
GROUP BY 1 ORDER BY 2 DESC;
```

Promotions and reactivation discounts show up here, and they explain most of the variance in what ClassPass pays.

### Which classes and instructors attract aggregator volume

`class_name` and `instructor_name` ride in `source_extras`. Cross-reference with `get_class_utilisation` on the booking source to see whether ClassPass is filling classes that were already full — which is a very different conclusion from filling empty ones.

### Keep it out of headline revenue

The canonical glossary treats aggregator flows as an excluded bucket. Report ClassPass payout as its own line, not folded into membership revenue, and never count a ClassPass sale as an extra visit — the visit is already in `bookings.attendance` under the booking system's source.

### What doesn't work

No purpose-built tool applies. `list_at_risk_members` deliberately excludes ClassPass members — they aren't expected to return, so treating them as lapsing is noise. Per-member ClassPass analysis is limited to whatever matched; the unmatched remainder is real revenue with no known member and should be reported as its own bucket.

## Where this lives in the code

| Concern                                                                            | Path                                                                 |
| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| The engine — parsing, Stage 1 landing, Stage 2 projection, rematch, reconciliation | `services/intelligence/internal/classpass/`                          |
| Console front door (multipart upload, Kinde-gated)                                 | `services/intelligence/internal/admin/httpapi/ingestor_classpass.go` |
| Chat front door (MCP tools, on mcp-runtime)                                        | `services/intelligence/internal/tools/classpass_v2.go`               |
| Operator UI                                                                        | `/ingestors/classpass` in `apps/kula-org-admin`                      |
| Sample export shape                                                                | `services/ingestors/classpass/demodata/README.md`                    |
| Operator-facing connect guide                                                      | [CSV & ClassPass connector](/your-data-sources/csv)                  |


# Xero — ontology map

What Xero gives Kula Intelligence, what it cannot give, and why the derived (API) and gl\_import (CSV) feeds must never be summed together.

**Source id:** `com.xero` (one id for both feeds — see below) · **Role:** general ledger / accounting — a **satellite** source, not a system of record for the studio floor · **Access:** Xero Custom Connection (OAuth2 client-credentials, studio's own Xero subscription pays for it), AU/NZ/UK/US only, **plus** an optional GL CSV upload for studios without one

Xero knows the money side of the business as Xero itself understands it — accounts, invoices, bills, payments, bank transactions — and, for studios that upload a GL export, the *actual* posted ledger including payroll and depreciation. It knows nothing about classes, attendance, instructors, or members as people; a Xero-only studio has a GL and no operations, and its contacts are counterparties (customers, suppliers, contractor companies), not `people.member` rows.

The single most important thing to understand about Xero here is the **two-feed model**.

## The two feeds: derived vs gl\_import

| Feed           | Where it comes from                                                                                                           | `origin`    | What it is                                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------- |
| **derived**    | The live Custom Connection, synthesised from source documents (invoices, bills, payments, bank transactions, manual journals) | `derived`   | P\&L lines only — a live, incremental, API-shaped view of revenue and expense                                  |
| **gl\_import** | An operator-uploaded Xero Journal Report / Account Transactions / Trial Balance CSV                                           | `gl_import` | The full posted GL for the uploaded period — including payroll and depreciation journals the API can never see |

Both feeds write to the **same** `source = 'com.xero'` — there is no `com.xero.gl` split. The discriminator is the `origin` column on `accounting.entry` (and, for `accounting.journal`, whether `source_external_id` carries the `glimport:` prefix — see Counting traps). **Never read the base tables directly**; `accounting.entry_guarded` and `accounting.journal_guarded` resolve the two feeds to exactly one per `(period_year, period_month)` — gl\_import wins for any period it covers, derived fills the rest. Summing both for a period double-counts.

A studio can run either feed alone, or both (CSV to backfill history and capture payroll/depreciation; API for near-real-time coverage of what it *can* see).

## Coverage at a glance

| Xero entity        | Canonical home                                                        | Grain                                                        | Window / freshness                                     |
| ------------------ | --------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
| Organisation       | (read at connect time only — org name, currency, country)             | one per tenant                                               | full pull, not stored canonically                      |
| Contacts           | `people.company` (+ `accounting.contact_link` once resolved)          | one per contact                                              | incremental (`UpdatedDateUTC` watermark)               |
| Accounts           | `accounting.account`                                                  | one per chart-of-accounts code                               | incremental                                            |
| TrackingCategories | `accounting.tracking_category`                                        | one per category+options                                     | full pull (unpaged, no watermark)                      |
| Items              | raw only (`ingest.raw_record`, `poll.items`) — not mapped canonically | —                                                            | incremental                                            |
| TaxRates           | raw only — not mapped canonically                                     | —                                                            | full pull (unpaged, no `UpdatedDateUTC`)               |
| Invoices           | `accounting.journal` (header) + `accounting.entry` (lines)            | one journal + N entry lines                                  | **date-windowed** on `Date`, `journal_type='invoice'`  |
| CreditNotes        | `accounting.journal` + `accounting.entry`                             | one journal + N lines                                        | date-windowed, folds into invoice-shaped entries       |
| Payments           | `accounting.journal` **header only**                                  | one journal, no entry lines                                  | date-windowed, `journal_type='payment'`                |
| Overpayments       | `accounting.journal` **header only**                                  | one journal, no entry lines                                  | date-windowed, `journal_type='overpayment'`            |
| Prepayments        | `accounting.journal` **header only**                                  | one journal, no entry lines                                  | date-windowed, `journal_type='prepayment'`             |
| BankTransactions   | `accounting.journal` + `accounting.entry`                             | one journal + N lines                                        | date-windowed, `journal_type='bank_transaction'`       |
| BankTransfers      | `accounting.journal` **header only**                                  | one journal, no entry lines                                  | date-windowed, `journal_type='bank_transfer'`          |
| ManualJournals     | `accounting.journal` + `accounting.entry`                             | one journal + N lines                                        | date-windowed, `journal_type='manual'`                 |
| GL CSV import      | `accounting.entry` + `accounting.journal`, `origin='gl_import'`       | whatever the export contains, including payroll/depreciation | as of the last upload — operator-driven, not scheduled |

If a row isn't in this table, we don't have it — see What we cannot get.

Every line carries `tracking` (JSONB) when the source document has Xero Tracking Categories applied (e.g. `[{"name":"Location","option":"Downtown"}]`) — join to `accounting.tracking_category` for the category's full option set.

## What we cannot get

**Tier/vendor-gated:**

* **Posted GL (`/Journals`) is Xero Advanced-tier only** (roughly $895–1,445/month plus a security assessment), and post-April-2026 Custom Connection scopes exclude journals entirely. We cannot read Xero's own journal feed — instead we **synthesise** P\&L entries from source documents (`origin='derived'`).
* **Payroll journals are invisible** to accounting API scopes — Xero Payroll posts its own system journals that the Accounting API never exposes. Manual-journal payroll (an operator posting payroll by hand) IS captured; the Xero Payroll API itself is deliberately not wired. GL CSV upload covers the rest.
* **Depreciation and other fixed-asset system journals** — same class of gap as payroll. `/Journals` or a GL CSV import are the only paths.
* **Unreconciled bank statement lines** — there is no public API for raw bank feed data (Bank Feeds is partner-restricted). `BankTransactions` are coded/reconciled entries only, so cash here is "as coded in Xero," never "as sitting at the bank."
* **Custom Connections are AU/NZ/UK/US only**, and the studio pays for it directly (roughly $10/month on their own Xero subscription). Studios outside those regions are GL-CSV-only.
* **5,000 API calls/day and 60/minute per tenant** — a large org's first backfill can legitimately take several days; see Counting traps.
* **No webhooks** — this is a poll-based ingestor. Coverage is as fresh as the last sweep or on-demand sync, never real-time.

**Product doesn't do it:**

* **Zero `bookings.*` coverage.** No members, classes, attendance, or appointments. Xero doesn't know what a class is.
* **No member-level revenue.** Xero contacts are settlement-level — a Stripe payout landing as one lump deposit is one Xero contact, not the members behind it. Member money lives on the Stripe/MBO/Wix axes; `accounting.contact_link` is an opt-in join (propose → confirm), and analytics only ever read `status='confirmed'` links.

**Architectural consequences (deliberate design, not gaps to fix):**

* **The derived feed is P\&L only.** AR/AP and bank control accounts are intentionally not synthesised, so there is **no balance-sheet math on the API path** — Trial Balance needs the GL CSV import.
* **Payments, credit notes, overpayments, prepayments and bank transfers land as journal header only** — cash-timing signal, no P\&L lines. This prevents double-counting the invoice/bill lines that already carry the P\&L side.
* Only `AUTHORISED`/`PAID` invoices, `AUTHORISED` bank transactions, and `POSTED` manual journals produce derived entries. `DRAFT`/`SUBMITTED` documents are invisible; a voided document flips its journal's `status` rather than disappearing (join on `status`, don't assume absence means never-existed).
* Items and TaxRates land raw only (`ingest.raw_record`) — join keys (`accounting.invoice_line.item_code`, `accounting.entry.tax_code`) already exist on the canonical lines; the reference tables themselves aren't projected.

## Identity and join keys

| Thing                                        | Xero id                              | Canonical                                                                                                                   |
| -------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| Contact                                      | `ContactID` (GUID)                   | `accounting.contact_link.contact_external_id`, resolved via `entity_lookup(type=company)` or a confirmed link's `target_id` |
| Account                                      | `Code`                               | `accounting.account.source_external_id`, `accounting.entry.account_code`                                                    |
| Tracking Category                            | `TrackingCategoryID`                 | `accounting.tracking_category.source_external_id`; each entry's `tracking` JSONB carries `{name, option}` pairs, not ids    |
| Item                                         | `Code`                               | `accounting.invoice_line.item_code` (raw item detail lives in `ingest.raw_record`, not projected)                           |
| Tax Rate                                     | `TaxType`                            | `accounting.entry.tax_code` (no GUID — `TaxType` itself is the natural key)                                                 |
| Invoice/Bill/Bank Transaction/Manual Journal | vendor GUID or natural-key hash      | `accounting.journal.source_external_id`                                                                                     |
| GL CSV row                                   | deterministic hash of the export row | `accounting.journal.source_external_id`, prefixed `glimport:`                                                               |

A Xero contact is **settlement-level**, not a person. `accounting. contact_link` resolves one to a `people.staff` / `people.company` / `people.identity_link`-canonical member / `people.location` row — `target_type` says which. Resolution is **opt-in and two-step**: `propose_contact_match` (AI, `status='proposed'`) then `confirm_contact_match` (operator, `status='confirmed'`). Only confirmed links feed `accounting.journal_resolved` — an unmatched or still-proposed contact leaves `resolved_target_id` NULL, which is the normal state for most contacts, not a bug.

Cost splitting works the same way: `accounting.allocation` splits a journal or a recurring account-code rule across `location` / `class_category` / `staff`, proposed via `propose_allocation` and made authoritative via `confirm_allocation`. `accounting.entry_allocated` and `accounting.teacher_cost_month` include only confirmed splits — an entry with no confirmed allocation is simply absent from those views, not zeroed out.

## Counting traps

**derived vs gl\_import — the one that matters most.** Query `accounting.entry_guarded` / `accounting.journal_guarded`, never the base tables. A raw `SUM(accounting.entry.debit)` over a period that has both an API-derived line and a GL-imported line for the same month double-counts every time.

**Payments/credit notes/overpayments/prepayments/bank transfers have NO entry lines** — only a journal header. `SUM(accounting.entry.debit) FROM ... JOIN accounting.journal WHERE journal_type IN ('payment', 'overpayment', ...)` returns nothing (correctly) because these entries don't exist; that is not a missing-data bug.

**Multi-currency is real here and not converted.** `CurrencyRate` is read from Xero but not applied — every amount is in the document's own currency. A multi-currency studio's rows will have mixed `currency` values across `accounting.entry`/`journal`; **group by currency, never blind-`SUM`** (exactly like `accounting.entry_allocated` and `accounting. teacher_cost_month` already enforce by design).

**DRAFT and SUBMITTED documents are invisible, and voids don't delete.** A draft invoice never produces a derived entry — don't read its absence as "no sales this period," check `get_system_status` and the coverage window first. A voided invoice's journal flips to `status='voided'`; the row is still there, so filter on `status`, don't assume every open-ended `SELECT` is already excluding it.

**Edited invoices can leave a stale line.** Xero line items are GUID-keyed but not tombstoned on our side when a line is removed after initial sync — a subsequent full re-pull (datafix) or the journal's `total_amount` versus the summed entry lines is the drift check.

**Day-cap-partial first backfills are normal, not stuck.** A large org's initial pull can legitimately span several days under Xero's 5,000 calls/tenant/day ceiling — `get_system_status` and the coverage card show progress; a "day 2 of 5" backfill is working as designed, not broken.

**Axis separation.** A Xero sales invoice and a Stripe charge for the same revenue event are different axes recording different things — never add `accounting.entry` into `commerce.payment` or `commerce.sale` totals. `commerce.payment.settlement_batch_id` is a reserved hook for a future payout-reconciliation join; it is not populated yet.

## Questions this source can and can't answer

**Can answer well**

* P\&L by account/category/period, from whichever feed (or blend) covers that period — via `accounting.entry_guarded`
* Cash timing on payments, credit notes, bank transactions, overpayments, prepayments and transfers (journal-header level)
* Cost of a specific instructor/location/class-category, once contacts and allocations are confirmed — `accounting.teacher_cost_month`, `accounting.entry_allocated`
* Who a settlement-level contact resolves to on the people side, once confirmed — `accounting.journal_resolved`
* Whether this studio's Xero connection (or CSV import) is current — `get_system_status`

**Cannot answer from Xero alone**

* Anything operational — classes, attendance, instructors, members as people *(not in Xero)*
* Balance sheet position from the API feed *(derived is P\&L only — needs a GL CSV Trial Balance import)*
* Payroll or depreciation detail *(invisible to API scopes — GL CSV only)*
* Per-member revenue *(Xero contacts are settlement-level — join through `accounting.contact_link`, and expect an unmatched remainder)*
* Anything before the earliest backfilled window, or before a GL CSV import's covered period

**When both a live Xero connection and a GL CSV import exist**, the CSV wins per period it covers and the API fills the rest — `accounting.entry_guarded` already resolves this; don't attempt to pick a feed yourself.

## Recipes — what works well

### Is the data current → `get_system_status`

Xero is polled (backfill + incremental sweep), not webhook-driven, and a first backfill on a large org can span several days by design. Check here before assuming staleness is a bug.

### P\&L by account, correctly de-duplicated

```sql
SELECT account_code, account_name, sum(debit) - sum(credit) AS net
FROM accounting.entry_guarded
WHERE source = 'com.xero' AND posted_at >= now() - interval '90 days'
GROUP BY 1, 2 ORDER BY 3 DESC;
```

Reading `entry_guarded` (not `accounting.entry`) is what makes this safe against the derived/gl\_import overlap.

### Resolving a contact to a person → `entity_lookup(type=company)`, then `propose_contact_match` / `confirm_contact_match`

```sql
-- Which Xero contacts have no confirmed people-side identity yet?
SELECT DISTINCT contact_external_id, contact_name
FROM accounting.journal_resolved
WHERE source = 'com.xero' AND resolved_target_id IS NULL
ORDER BY contact_name;
```

Propose with `auto=true` first (deterministic email/exact-name match against `people.company`/`people.staff`) — it reports `no_match`/`ambiguous` rather than guessing, so a human only needs to look at the genuinely unclear ones.

### What did this instructor actually cost us → `accounting.teacher_cost_month`

```sql
SELECT period_year, period_month, currency, sum(cost_debit)
FROM accounting.teacher_cost_month
WHERE staff_id = $1
GROUP BY 1, 2, 3 ORDER BY 1, 2;
```

Zero rows means no confirmed staff-dimension allocation exists yet for this person — propose one with `propose_allocation`, not a sign that the instructor cost nothing.

### Cash timing on payments/bank activity (no P\&L double-count)

```sql
SELECT journal_type, date_trunc('month', posted_at) AS month, sum(total_amount)
FROM accounting.journal_guarded
WHERE source = 'com.xero' AND journal_type IN ('payment', 'bank_transaction', 'bank_transfer')
GROUP BY 1, 2 ORDER BY 2;
```

### Update-to-now vs a full re-pull

The connector page's "Update to now" runs an incremental `If-Modified-Since` sync from the watermark — safe to run anytime, cheap, and it is **not** the same operation as a windowed re-pull or a per-entity datafix (those force-reload a date range or a whole entity and are heavier). Coverage-gap re-pulls in the operator console use the windowed path automatically; the sync button never does.

### What doesn't work on Xero alone

`list_at_risk_members`, `get_class_utilisation`, `get_teacher_performance` and every other bookings-shaped tool return empty on a Xero-only org — there is no booking data for them to read, and empty is the correct answer, not a fault to chase.

## Where this lives in the code

| Concern                                                                            | Path                                                                                                                                                        |
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Entity catalogue (specs, `Dated`/`ModifiedSince`, the `where=Date` window builder) | `services/ingestors/xero/internal/xeroclient/specs.go`                                                                                                      |
| Stage-1 pull (watermark vs windowed, day-cap, per-page checkpoints)                | `services/ingestors/xero/internal/orchestrator/stage1.go`                                                                                                   |
| Transforms (journal-header-only entities, tracking passthrough)                    | `services/ingestors/xero/internal/transform/`                                                                                                               |
| Two-feed resolution views                                                          | `services/intelligence/migrations/postgres/org/950_guarded_views/003_entry_guarded_feed_resolution.sql`, `004_journal_guarded_feed_resolution.sql`          |
| Contact/allocation tables + analytics views                                        | `services/intelligence/migrations/postgres/org/400_accounting/006_contact_link.sql`, `007_allocation.sql`, `950_guarded_views/006_accounting_analytics.sql` |
| Matching/allocation MCP tools                                                      | `services/intelligence/internal/tools/accounting_links.go`                                                                                                  |
| GL CSV import + staleness reminder                                                 | `services/intelligence/internal/admin/httpapi/ingestor_xero_gl.go`, `nightly_xero_gl_reminder.go`                                                           |
| Operator-facing connect guide                                                      | [Xero connector](/your-data-sources/xero)                                                                                                                   |


# QuickBooks — ontology map

What QuickBooks Online gives Kula Intelligence, what it cannot give, and why the books axis must never be summed against the revenue axis.

**Source id:** `com.quickbooks` · **Role:** general ledger / reconciliation — a **satellite** source, never the system of record for members or bookings · **Access:** QuickBooks Online v3 REST API, OAuth (`minorversion` 75), one company file per Kula org

QuickBooks is the studio's books — the ledger a bookkeeper keeps and the tax return is filed from. It knows what money was *recognised*: categorised, tax-treated, reconciled. It knows nothing about members, classes, attendance or instructors, and in most studios it doesn't even know about individual customers — revenue usually arrives in the books as daily-sales summaries or processor settlement batches, not people.

The single most important thing to understand about QuickBooks here is the **two-axis model**.

## The two axes: books vs behaviour

| Axis                    | Systems                                                      | Canonical home             | What it means                                                                    |
| ----------------------- | ------------------------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------- |
| **Behaviour / revenue** | Booking + payment systems (Mindbody, Wix, GymMaster, Stripe) | `commerce.*`, `bookings.*` | What was sold and collected — per member, per class, per charge                  |
| **Books**               | QuickBooks (or Xero)                                         | `accounting.*`             | What the bookkeeper recognised — the general ledger, categorised and tax-treated |

The same dollar legitimately exists on **both** axes: a Stripe charge in `commerce.payment` *and* the settlement posted into the books in `accounting.entry`. That is by design, and it is why **nothing may ever sum across the axes** — revenue questions go to `commerce.*`, GL/reconciliation questions go to `accounting.*`, and adding them double-counts every dollar. Comparison is the only legitimate cross-axis operation: put the two numbers side by side, never in one sum.

Within the books axis QuickBooks plays the same role as Xero: a studio connects one or the other, never both.

## Coverage at a glance

| Canonical home                                                | Grain                                                         | History                                                  | Freshness         |
| ------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------- | ----------------- |
| `accounting.entry` (`origin = 'derived'`)                     | one GL line — debit **or** credit, never both                 | backfill, default 24 months, extendable to company start | nightly CDC sweep |
| `accounting.journal`                                          | one header per source document                                | same                                                     | nightly CDC sweep |
| `accounting.invoice_line`                                     | **one row per invoice / sales-receipt line**, not per invoice | same                                                     | nightly CDC sweep |
| `accounting.account`                                          | one per chart-of-accounts row                                 | current snapshot                                         | nightly CDC sweep |
| `people.company` (+ `accounting.contact_link` once confirmed) | one per QBO customer or vendor                                | current snapshot                                         | nightly CDC sweep |

Which QBO documents produce what:

* **Sales — journal + `invoice_line` + revenue entries (credit):** Invoice (`journal_type = 'invoice'`), SalesReceipt (`'receipt'`).
* **Refunds and credits — journal + revenue-*****reducing*****&#x20;entries (debit):** CreditMemo and RefundReceipt (`'adjustment'`). VendorCredit (`'adjustment'`) is the expense-side mirror — it credits the expense account, reducing spend.
* **Expenses — journal + expense entries (debit):** Bill (`'bill'`), Purchase — expenses, cheques, card spend (`'bank_transaction'`). Deposit (`'bank_transaction'`) is money in, so its direct lines credit.
* **Manual journals — journal + both sides:** JournalEntry (`'manual'`), the one document where Σdebit = Σcredit within the fan-out.
* **Journal header only — no entry lines:** Payment and BillPayment (`'payment'`), Transfer (`'bank_transaction'`). These are cash / AR / AP movements with no P\&L effect — see Counting traps.
* **Never a guessed side.** A Deposit line referencing a linked transaction or carrying no account, and a JournalEntry line with an unreadable posting type, demote the *whole* document to header-only.
* **TaxCode / TaxRate** land raw only (re-pulled weekly). They are *not* joined into `accounting.entry` — see the per-line-tax gap below.

**`category` is populated — but asymmetrically, and you must know which way.** Expense-side lines (Bill, Purchase, Deposit, VendorCredit, JournalEntry) carry `category`, `account_code` and `account_name`, resolved against the landed chart of accounts. Sales *item* lines (Invoice, SalesReceipt, CreditMemo, RefundReceipt) carry `category = 'revenue'` and **no `account_code`** — a QBO sales line references an *Item*, not an account, and the Item entity isn't pulled in Phase A. So expenses break down by account; **revenue does not**. Break revenue down by `category`, or by `invoice_line.item_name` on the invoice side. (The one exception: a discount line references QBO's own discount account, so it does carry a code.)

**Discount and bundle lines are included.** Discount lines land as entries on the *opposite* side to their siblings, and bundle (`GroupLineDetail`) lines are expanded into their nested components, so Σ(credits − debits) across one journal's entries reconciles to `accounting.journal.total_amount`. Discounts are entries only — they get no `accounting.invoice_line` row, so a `sum(line_total)` is gross of discount.

Amounts are normally in the **home currency** (multiplied through QBO's exchange rate on a foreign-currency document), so the GL sums like a GL; the original currency and rate ride in `source_extras`. One exception: a foreign document with no usable exchange rate keeps its *transaction* currency, and the `currency` column says so rather than mislabelling it — so group by `currency` before summing if a studio bills in more than one. If a row isn't in this table, we don't have it.

## What we cannot get

Each item names its gap kind. Most are hard limits of QuickBooks, not gaps in our implementation.

**Anything behavioural.** Members, attendance, bookings, classes, visits, door access. QuickBooks is a ledger; it records money, not behaviour. `bookings.*` and `people.member` get nothing from this source. *(product doesn't do it)*

**Per-member revenue, in most books.** QBO "Customers" are whoever the bookkeeper invoices — frequently one "Daily Sales" customer or POS/Stripe settlement summaries, not people. Member-level money lives on the revenue axis (Stripe/MBO/Wix). Where a studio genuinely invoices individuals, `accounting.contact_link` can join them — after operator confirmation, never automatically. *(product doesn't do it — depends on the studio's bookkeeping practice)*

**Tax per line.** `accounting.entry.tax_amount` is **never populated** from QuickBooks — QBO reports tax at the *document* level only (`TxnTaxDetail.TotalTax`), which lands in `accounting.journal.source_extras.total_tax`. And `accounting.entry.tax_code` holds QBO's **opaque numeric `TaxCodeRef` id** (e.g. `"5"`), an internal pointer rather than a BAS/VAT label; the label sits in the raw `poll.taxcode` rows we land but never join. GST/BAS questions belong to the journal-level total or QuickBooks' own tax reports, never a per-line sum. *(vendor has no API)*

**Which GL account a sale posted to.** QBO sales lines reference an Item; the revenue account behind it lives on the Item entity, which Phase A deliberately doesn't pull. Revenue entries carry `category = 'revenue'` with a NULL `account_code`. *(endpoint exists, not wired)*

**Payroll detail.** Intuit has no public payroll API (its GraphQL beta is US-only, compensation-read, paid tier). Wages reach the books only as posted lump-sum GL lines — no per-staff labour cost. *(vendor has no API)*

**The audit trail.** QBO's audit log is UI-only. We cannot see who changed a transaction or when, beyond `MetaData.LastUpdatedTime`. *(vendor has no API)*

**Bank-feed "For Review" lines.** Unaccepted bank transactions sit in a closed Intuit system; only ledger-posted transactions are readable. The books we mirror are only as current as the studio's bookkeeping. *(vendor has no API)*

**Card-processing detail** — per-charge fees, payouts, disputes — when the studio uses QuickBooks Payments. That is a separate API we don't wire; for Stripe studios the same detail is already on the payment axis. *(endpoint exists, not wired)*

**Time-of-day money.** `TxnDate` is date-only; QBO transactions carry no business timestamp. Revenue by hour or class time is impossible from the books — that question belongs to the booking/payment sources. *(destroyed at source)*

**Deletes that happened while the connector was down for more than 30 days.** QBO transactions hard-delete and its change feed reports deletions for only 30 days. There is **no Id-inventory backstop** — a document deleted during a longer outage stays in `accounting.*` as a row QuickBooks no longer has, and nothing in the pipeline notices. A periodic inventory diff is Phase B work, not something running today. *(live-forward only)*

**Custom fields beyond the legacy three** on sales forms (the 12-field model is a paid GraphQL API), and Class/Department dimensions — those arrive only in `source_extras` for now. *(endpoint exists, not wired)*

**QuickBooks Desktop.** No cloud API exists; a QBD company file is invisible to this integration. QuickBooks **Online** only. *(vendor has no API)*

**Draft and pending documents.** We ingest posted-equivalent states only; drafts and estimates are not money. *(not pulled, by design)*

## Identity and join keys

The **realmId** — QBO's id for one company file — is both the `site_id` on every row and the **namespace prefix** on every id the document fan-out mints, so lines from two company files can never collide on `(source, source_external_id)`.

| Thing                                | QBO id                   | Canonical                                                                                                                                                                                                        |
| ------------------------------------ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Company file                         | `realmId`                | `site_id` on every row; prefix of the document/line ids below                                                                                                                                                    |
| Document (invoice, bill, payment, …) | `Id`                     | `accounting.journal.source_external_id` = `<realmId>:<Id>`                                                                                                                                                       |
| GL line                              | `<parentId>:<lineId>`    | `accounting.entry.source_external_id` = `<realmId>:<parentId>:<lineId>`; the parent document in `journal_source_external_id`, same namespaced form                                                               |
| Invoice / sales-receipt line         | `<parentId>:<lineId>`    | `accounting.invoice_line.source_external_id`, same shape; the parent in `invoice_external_id`                                                                                                                    |
| A bundle's nested line               | `<groupLineId>:<lineId>` | `<realmId>:<docId>:<groupLineId>:<lineId>` — QBO numbers a group's sub-lines from 1 *within* the group, so the group id has to qualify them                                                                      |
| Account                              | `Id`                     | `accounting.account.source_external_id` — **not** namespaced (the SQL projector writes the bare id); `code` = `AcctNum`, falling back to `FullyQualifiedName` then `Id` where the studio doesn't number accounts |
| Customer / Vendor                    | `Id`                     | `people.company.source_external_id` (`source = 'com.quickbooks'`) — also the bare id                                                                                                                             |

`entry.account_code` carries the chart's `code`, not QBO's internal `AccountRef.value`, so it joins `accounting.account.code`. A ref the chart doesn't know leaves the attribution empty rather than writing an id that joins to nothing.

**One company file per Kula org.** `accounting.*` keys on `(source, source_external_id)` with the realm folded into the id — there is no per-realm partition in the schema — and mcp-admin stores exactly **one** QuickBooks connection per org. So a genuinely multi-entity operator needs **one Kula org per company file**, not one org holding several connections. If an org is ever reconnected to a *different* company file, the new realm's rows land alongside the old realm's: they no longer collide, but the old rows linger as stale history that no sweep will refresh and no delete will clear. Filter on `site_id` when that has happened.

A QBO customer or vendor is a **counterparty, not a person**. Resolution to the people side goes through `accounting.contact_link`, and it is **opt-in and two-step**: `propose_contact_match` (AI, `status='proposed'`) then `confirm_contact_match` (operator, `status='confirmed'`). There is **no automatic email matching** — only confirmed links feed `accounting.journal_resolved`, and a contact with no confirmed link is the normal state for most books, not a bug.

## Counting traps

**Two axes, never summed together.** The same dollar legitimately exists in `commerce.payment` (Stripe) and `accounting.entry` (books). Revenue questions → `commerce.*`; GL and reconciliation → `accounting.*`. Compare the two; never add them.

**`accounting.entry` has no `amount` column** — debit and credit only, one side per row. Net revenue is `sum(credit) − sum(debit)` over `category = 'revenue'`; net expense is the reverse over `category IN ('expense','cogs')`. There is nothing to `SUM(amount)`. And because debit and credit are mutually exclusive, one of the two `sum()`s is NULL in any group holding a single side — **`coalesce()` both** or the subtraction silently returns NULL.

**Refunds already reduce revenue — don't subtract them twice.** CreditMemo and RefundReceipt fan out debit entries against `category = 'revenue'`, and VendorCredit credits the expense side. A revenue figure computed as credits-minus-debits is therefore *already* net of credit notes and refunds. Subtracting the `'adjustment'` journals' `total_amount` on top of it double-counts every refund.

**Payments, bill payments and transfers are journal headers only.** They are cash / AR / AP movements with no P\&L effect — the revenue or expense was booked by the invoice or bill. Counting a payment journal as revenue double-counts the invoice that raised it, and joining one expecting entry lines returns nothing, correctly.

**Don't group revenue by `account_code`.** It is NULL on every sales item line (QBO sales lines point at Items, not accounts) — only discount lines carry one. A `GROUP BY account_code` over revenue drops nearly everything into one nameless bucket alongside a lonely "Discounts given".

**No per-line tax.** `entry.tax_amount` is always NULL here, and `entry.tax_code` is an opaque numeric id, not a tax label. A `sum(tax_amount)` over QuickBooks entries returns NULL, not zero — and a `GROUP BY tax_code` groups by internal ids nobody can read.

**Query the guarded views, and know they lag a day.** `accounting.entry_guarded`, `journal_guarded` and `invoice_line_guarded` resolve GL-import periods over derived ones *and* cut off at `CURRENT_DATE` — today's postings are deliberately invisible. The base tables are blocked to the read path anyway; asking for "today" and getting nothing is the view working as designed.

**Voided documents remain as zero-amount rows.** QBO keeps a voided transaction queryable with its amounts zeroed, and we mirror that (`source_extras.voided = true`). Sum amounts; never count rows as activity.

**An edited-away line leaves a stale row.** The fan-out upserts by line id and never retracts an individual line — delete line 3 of a five-line invoice in QuickBooks and its `entry` / `invoice_line` row stays behind, exactly as with Xero. Only deleting the *whole* document retracts (`accounting.journal_removed` clears the journal and its children). When a document's numbers look wrong, reconcile its entries against `journal.total_amount`.

**Refunds aren't linked to their original sale.** A RefundReceipt carries no hard link to the document it refunds — refund attribution within the books is heuristic at best.

**The derived lane understates expenses.** Until the GL-report lane (`origin='gl_import'`) lands for QuickBooks, payroll and depreciation journals are absent — so a derived-lane P\&L is decision-grade for revenue and coded operating spend, but **total expense reads low**. Say so when presenting a bottom line.

## Questions this source can and can't answer

**Can answer well**

* P\&L by month and category — from the derived lane, with the expense caveat above
* Expense breakdown by account (rent, software, cleaning, contractors)
* Refunds and credit notes against revenue, by month
* AR aging — who owes the studio, and how overdue
* Supplier spend, by vendor and over time
* Revenue-vs-books drift — what the processor collected vs what the books recognised, side by side

**Cannot answer from QuickBooks alone**

* Any member, attendance or retention question *(not in the books)*
* Revenue by class, instructor, or time of day *(TxnDate is date-only, and the books don't know what a class is)*
* Revenue by GL account *(sales lines reference Items, not accounts)*
* GST or tax per line, or by readable tax label *(QBO reports tax at the document level, with an opaque code id)*
* Per-member lifetime value *(customers are counterparties, often settlement summaries)*
* Payroll cost per staff member *(no public payroll API)*
* "Who deleted this transaction?" *(audit log is UI-only)*

**Phrase a gap as the vendor's limit, not missing data.** "QuickBooks has no public payroll API, so wages only appear as posted totals" is correct and useful; "your data is incomplete" is not.

## Recipes — what works well

### Monthly P\&L by category → `accounting.entry_guarded`

Debit/credit only — there is no `amount` column — always the guarded view, and `coalesce()` both sides or a single-sided month returns NULL:

```sql
SELECT period_year, period_month,
       coalesce(sum(credit) FILTER (WHERE category = 'revenue'), 0)
         - coalesce(sum(debit)  FILTER (WHERE category = 'revenue'), 0)             AS net_revenue,
       coalesce(sum(debit)  FILTER (WHERE category IN ('expense','cogs')), 0)
         - coalesce(sum(credit) FILTER (WHERE category IN ('expense','cogs')), 0)   AS net_expense
FROM accounting.entry_guarded
WHERE source = 'com.quickbooks'
GROUP BY 1, 2 ORDER BY 1, 2;
```

`net_revenue` is already net of credit memos and refund receipts — don't net them again. And name the derived-lane caveat: payroll and depreciation are absent until the GL lane, so expenses read low.

### Expense breakdown → `entry_guarded` by `account_code` + `category`

Works on the expense side *because* expense lines carry account attribution — there is no revenue equivalent.

```sql
SELECT category, account_code, account_name,
       coalesce(sum(debit), 0) - coalesce(sum(credit), 0) AS spend
FROM accounting.entry_guarded
WHERE source = 'com.quickbooks'
  AND category IN ('expense', 'cogs')
  AND posted_at >= now() - interval '12 months'
GROUP BY 1, 2, 3 ORDER BY 4 DESC;
```

`account_code` is `AcctNum` where the studio numbers accounts and the fully-qualified name where it doesn't — group by `account_name` too, as above, so the output reads either way.

### Tax: the document total, not a per-line sum

There is no per-line tax. The document total QBO *does* give lands in the journal's `source_extras`, and it is the only tax number this axis has:

```sql
SELECT date_trunc('quarter', posted_at)                              AS quarter,
       journal_type,
       coalesce(sum((source_extras->>'total_tax')::numeric), 0)      AS document_tax
FROM accounting.journal_guarded
WHERE source = 'com.quickbooks'
  AND source_extras ? 'total_tax'
GROUP BY 1, 2 ORDER BY 1, 2;
```

Sales-side `journal_type` (`invoice`, `receipt`) is tax collected; expense-side (`bill`, `bank_transaction`) is tax paid. Indicative only — **not a BAS/VAT filing figure**. The lodgeable number comes from QuickBooks' own tax reports, which apply adjustment/rounding/period rules we don't mirror. Say so rather than presenting it as final.

### AR aging → `accounting.invoice_line_guarded`

`invoice_status` from QuickBooks is only ever `sent`, `paid` or `void` — there is no `'overdue'` to filter on, so "outstanding" means `'sent'` and the buckets come from `invoice_date`:

```sql
SELECT CASE WHEN invoice_date > now() - interval '30 days' THEN '0-30'
            WHEN invoice_date > now() - interval '60 days' THEN '31-60'
            WHEN invoice_date > now() - interval '90 days' THEN '61-90'
            ELSE '90+' END                       AS age_bucket,
       count(DISTINCT invoice_external_id)       AS invoices,
       coalesce(sum(line_total), 0)              AS outstanding_ex_tax
FROM accounting.invoice_line_guarded
WHERE source = 'com.quickbooks'
  AND invoice_status = 'sent'
GROUP BY 1 ORDER BY 1;
```

The grain is one row per **line**, so invoice counts must be `DISTINCT` on `invoice_external_id`. And `line_total` carries neither document tax nor discount lines, so it is ex-tax and gross of discount — use it for the *shape* of the aging, and `journal_guarded.source_extras->>'balance'` when the number itself has to be right.

### Books vs processor → compare the axes, never join them

Two numbers side by side, each from its own axis:

```sql
WITH books AS (
    SELECT period_year AS y, period_month AS m,
           coalesce(sum(credit), 0) - coalesce(sum(debit), 0) AS recognised
    FROM accounting.entry_guarded
    WHERE source = 'com.quickbooks' AND category = 'revenue'
    GROUP BY 1, 2
), processor AS (
    SELECT extract(year FROM occurred_at)::int  AS y,
           extract(month FROM occurred_at)::int AS m,
           coalesce(sum(amount), 0)             AS collected
    FROM commerce.payment_guarded
    WHERE status IN ('captured', 'succeeded')
    GROUP BY 1, 2
)
SELECT coalesce(b.y, p.y) AS year, coalesce(b.m, p.m) AS month,
       b.recognised, p.collected
FROM books b FULL OUTER JOIN processor p ON p.y = b.y AND p.m = b.m
ORDER BY 1, 2;
```

Report both columns and the gap — never their sum. A gap is normal and usually explainable: timing (a December charge recognised in January), processor fees netted in the books, and cash posted as a settlement batch rather than per charge.

`accounting.entry.sale_source_external_id` is the per-record drill-down from a books line to the sale it recognises, and the column is honoured everywhere — but **no accounting feed populates it in Phase A**, QuickBooks included. A `WHERE sale_source_external_id = …` probe returns nothing today; use the comparison above rather than promising a per-charge trace.

### Is the data current → `get_system_status`, then the coverage window

`get_system_status` first — it reports QuickBooks as current / stale / failing with a plain-language "current as of" date. Then establish the window before charting, remembering the views stop at yesterday:

```sql
SELECT min(posted_at) AS books_from,
       max(posted_at) AS books_to,
       count(*)       AS lines
FROM accounting.entry_guarded
WHERE source = 'com.quickbooks';
```

The books are only as current as the bookkeeping — a transaction sitting in the bank feed's "For Review" list isn't posted and doesn't exist to us. A `books_to` that trails by weeks usually means the bookkeeper is behind, not the connector.

### What doesn't work on QuickBooks alone

`list_at_risk_members`, `get_class_utilisation`, `get_teacher_performance`, `get_retention_curve`, `get_member_payments` — everything member- or booking-shaped needs sources QuickBooks isn't. On a QuickBooks-only org they return empty, and that is the correct result, not a fault.

### The interesting questions are in the join

With a revenue source *and* QuickBooks connected the valuable analyses are cross-axis: what Stripe collected vs what the books recognised, by month; supplier and contractor spend against teacher performance (via confirmed `contact_link` rows); coded operating spend against the revenue that funded it. Keep each number on its own axis — present the comparison, never the sum.

## Where this lives in the code

| Concern                                                    | Path                                                                                                                                               |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ingestor (stage 1 pull + stage 2 restate)                  | `services/ingestors/quickbooks/`                                                                                                                   |
| Fan-out transforms (documents → journal + lines + entries) | `services/ingestors/quickbooks/internal/transform/`                                                                                                |
| 1:1 SQL projections (accounts, customers, vendors)         | `services/intelligence/internal/ingest/project/templates_quickbooks.go`                                                                            |
| Canonical tables                                           | `services/intelligence/migrations/postgres/org/400_accounting/`                                                                                    |
| derived/gl\_import resolution views                        | `services/intelligence/migrations/postgres/org/950_guarded_views/003_entry_guarded_feed_resolution.sql`, `004_journal_guarded_feed_resolution.sql` |
| OAuth connect flow                                         | `services/intelligence/internal/admin/httpapi/onboarding_quickbooks.go`                                                                            |
| Operator-facing connect guide                              | [QuickBooks connector](/your-data-sources/quickbooks)                                                                                              |


# The relationship graph — ontology map

What the derived relationship graph is built from, what it can and cannot know, how its weights and quadrants are grained, and the counting traps that make a graph query wrong.

**Layer id:** `graph.*` (a per-studio Postgres schema) · **Role:** a **derived** layer, not a source — it has no vendor, no credentials, no connector row · **Freshness:** as of the last build, not the live second

Every other map on this shelf describes a *vendor*: what Mindbody exposes, what GymMaster withholds. This one describes something we build ourselves. The relationship graph is computed **entirely from the canonical tables the connectors already fill** — it invents nothing, reaches no external system, and knows only what attendance, sessions, locations and plans imply.

That makes its failure mode different, and worse. A source map stops you claiming data you never received. This map stops you claiming *meaning* the graph never computed: reading an edge weight as a visit count, a quadrant as a churn prediction, or an empty rollup as "this member has no relationships" when the real answer is "the graph hasn't been built for this org yet".

> This is the coverage-and-honesty map. For the tool signatures, parameters and purpose semantics, see [Graph & relationship tools](/for-developers/graph). For the tables themselves, the [schema reference](/for-developers/schema).

## What this layer is

Three things live under `graph.*`, and conflating them is the single most common mistake:

|                     | What it is                                                                           | Table                                                                                                                                  |
| ------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| **The graph**       | Who is connected to whom, and how strongly — five classes of weighted, decayed edge  | `graph.node`, `graph.edge`                                                                                                             |
| **The rollups**     | Precomputed answers over those edges — routing, concentration, resilience, quadrants | `graph.member_affinity`, `graph.staff_concentration`, `graph.member_resilience`, `graph.location_resilience`, `graph.connection_score` |
| **The sense layer** | Derived facts that a member may need attention *now*                                 | `graph.risk_signal`                                                                                                                    |

And one more distinction the naming makes deliberately awkward, because getting it wrong produces nonsense:

* **`graph.signal`** — the raw, append-only observation stream (a class attended, a sale, a payment, an appointment). An *input*.
* **`graph.risk_signal`** — the at-risk trigger queue. An *output*.

They are not variants of each other. `get_open_signals` reads the second.

The build is one transaction: upsert nodes → build MS/MM/ML/SL/SS edges → classify → score → tier → rebuild every rollup → extract signals → detect risk signals. It runs nightly per org, and again after an import or restate that changes its inputs. Everything below is therefore **derived state with a `computed_at`**, never an event log.

## Coverage at a glance — what feeds the graph

| Canonical input                                                                                                     | Produces                                                                                 | Grain                         | Window / decay                           | If the input is missing                                                           |
| ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------- |
| `bookings.attendance` (`booking_status = 'attended'`) + `class_session.instructor_staff_id`                         | **MS** edge `attended` (member↔staff)                                                    | one per (member, staff) pair  | MS window, MS half-life                  | **No MS edges at all** — no affinity, no concentration, no resilience denominator |
| the same, self-joined on the session                                                                                | **MM** edge `co_attended` (member↔member)                                                | one per unordered member pair | MM window, MS/MM half-life, × `α_peer`   | No peer anchor — every member's resilience falls                                  |
| `bookings.attendance` → `class_session.location_id` → `people.location`                                             | **ML** edge `frequents` (member↔location)                                                | one per (member, location)    | ML window, ML half-life                  | **No ML edge ⇒ no `member_resilience` row ⇒ no quadrant**                         |
| `bookings.facility_entry` (granted entries, non-exit, `access_category <> 'class_attendance'`) → door's parent club | the **same ML** edge, summed in                                                          | one per (member, club)        | FL window, FL half-life, × `α_facility`  | Location attachment is class-only (fine for studios; understates a 24/7 gym)      |
| `class_session` instructor + secondary staff → location                                                             | **SL** edge `staff_of` (staff↔location)                                                  | one per (staff, location)     | SL window, SL half-life                  | No `location_resilience` row for that location                                    |
| co-instruction on one session                                                                                       | **SS** edge `co_teaches` (staff↔staff)                                                   | one per unordered staff pair  | SS window, SS half-life                  | Every teacher reads as a single point of failure                                  |
| `commerce.plan` (category / billing interval), falling back to `people.member.membership_type`                      | contract commitment **C** in `member_resilience`                                         | one per member                | current plan state                       | C degrades to its fallback — resilience skews                                     |
| `bookings.attendance`, `commerce.sale`, `commerce.payment`, `bookings.appointment`                                  | **`graph.signal` rows only** — activity counters on `graph.node`                         | one per source row            | none (full history)                      | The node's activity counters read as zero                                         |
| `people.member_status_event` (non-baseline rows)                                                                    | **`graph.signal` rows only**, kinds `status_changed` / `plan_started` / `plan_cancelled` | one per transition            | none, but the log itself is live-forward | No lifecycle signals — the graph can't see that a member went inactive            |

Nodes exist for exactly **three** entity types: `member`, `staff`, `location` — one per *canonical* row (`people.member` / `people.staff` where `canonical_*_id IS NULL`, and every `people.location`). The `entity_type` CHECK also admits `company`, `org_user`, `class_session`, `plan` and `product`; **nothing creates those nodes today**.

### What it produces

| Table                       | Grain                                                                      | Read it with                                   |
| --------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------- |
| `graph.edge`                | one per (from, to, kind); `edge_class` ∈ MS/ML/SL/MM/SS                    | `get_entity_edges`                             |
| `graph.connection_score`    | one per **MS pair** — score, 30-day delta, trend                           | `get_staff_relationship_review`                |
| `graph.member_affinity`     | one per member — primary coach, top-3, concentration %                     | `get_member_affinity`, `get_member_context`    |
| `graph.staff_concentration` | one per staff — `primary_member_count`, total member edges                 | `get_staff_concentration`, `get_staff_context` |
| `graph.member_resilience`   | one per **(member, location)** — R, A, C, R̃, `vuln_quadrant`              | `get_member_context`, `list_quadrant_members`  |
| `graph.location_resilience` | one per location — `ops_resilience`, cover depth, single points of failure | `get_location_context`                         |
| `graph.risk_signal`         | one per open trigger                                                       | `get_open_signals`                             |

## What we cannot get

**Money never becomes a relationship.** Sales and payments land in `graph.signal` and move a node's activity counters — they contribute **zero edge weight**. The only route commerce takes into a score is contract commitment (C), a per-member switching-cost term. So "who are my most valuable relationships" is not a graph question; it is a `commerce.payment_attribution` question joined to graph output afterwards.

**1:1 and PT relationships are invisible.** MS edges are built from *class* attendance only. Appointments produce an `appointment_attended` signal but never an edge — and in any case `bookings.appointment` is empty for every connected source today (Mindbody doesn't support it, GymMaster has no such concept, Wix's is unmapped). A studio whose strongest coach bonds are one-to-one has a graph that cannot see them.

**Declared or social relationships.** `graph.edge.kind` documents `referred_by`, `mentors`, `family_of`, `introduces`, `messages`, `follows`, `covers_for`, `contracts_with` — **none of them are ever written.** Five kinds exist in practice: `attended`, `co_attended`, `frequents`, `staff_of`, `co_teaches`. The MM edge was renamed from `workout_buddy` to `co_attended` precisely because the old name claimed a friendship the data never asserts: two people were in the same small room.

**Anything from messages or marketing.** `comms.*` and `marketing.*` feed nothing here.

**Relationships formed in big classes.** MM edges only come from classes at or below a size cap — above it, co-attendance is treated as coincidence, not connection. A 40-person spin studio will have a sparse peer graph by design.

**A relationship that predates the window.** Each edge class ages out on its own horizon. An edge older than its window is `lapsed` or gone; there is no lifetime-total view, and the graph deliberately cannot answer "who *used* to be close to whom".

**History of its own scores.** The graph is a rebuilt snapshot. There is no time series of past weights, past quadrants, or past resilience — the only built-in comparison is `connection_score.score_delta_30d` and its `trend`. "Show me how this member's resilience moved over six months" cannot be answered.

Note the one exception, and keep it straight: **member&#x20;*****status*****&#x20;history does exist**, in `people.member_status_event` — when someone went inactive, was suspended, or lost their plan, and why where that is knowable. That is a canonical fact the graph merely mirrors as `status_changed` / `plan_started` / `plan_cancelled` signals; it is not graph state, it survives a rebuild, and it says nothing about how any *score* moved. It is also **live-forward only** — no source system records status history, so the log begins when Kula started synthesising it, and each member's oldest row per dimension is an `is_baseline` first observation rather than a change.

**A churn prediction.** Resilience and the quadrants are *structural*: how exposed a member is to losing their coach. They are not a forecast, and `specialist_nomad` does not mean "leaving". Behavioural lapse is `list_at_risk_members` and `graph.risk_signal` — a different layer entirely.

**Cluster labels, edge rationales, confidence.** `graph.node.cluster_tags`, `graph.edge.rationale` and `graph.edge.confidence` are schema surface with no producer: tags are always empty, rationale always NULL, confidence always 1. Don't filter on them.

**The tuning values.** Half-lives, windows, `α_peer`, the class-size cap, β and every risk threshold are injected at deploy and are not published. The tools return the resulting scores and labels, never the parameters — and an untuned deployment silently uses neutral placeholders, so absolute weights are not comparable between deployments.

**Anything cross-org.** The graph is built inside one studio's database from that studio's rows. There is no shared or global graph.

## Identity and join keys

| Thing         | Graph key                              | Joins to                                   |
| ------------- | -------------------------------------- | ------------------------------------------ |
| Node          | `graph.node.id` (UUID)                 | `(entity_type, entity_id)` is a loose FK   |
| Member node   | `entity_id`                            | `people.member.id` — **the canonical row** |
| Staff node    | `entity_id`                            | `people.staff.id` — the canonical row      |
| Location node | `entity_id`                            | `people.location.id`                       |
| Rollups       | `person_id`, `staff_id`, `location_id` | the same `people.*.id` values              |

Three consequences that cause most wrong graph queries:

**`entity_id` is an internal UUID, not a vendor id.** AI clients routinely hold a `source_external_id` — most often the `instructor_staff_id` printed on a class session. The graph-context tools resolve either form, but raw SQL will not. Use `entity_lookup` to get the canonical id first.

**Identity-resolved duplicates have no node.** A studio on two sources has two `people.member` rows for one human; only the canonical one becomes a node, and edges are keyed on `COALESCE(canonical_member_id, id)`. That is what makes the graph the *deduplicated* view of a person — but it also means a node count and a naive `people.member` count won't agree. Count humans with `people.member_distinct`.

**Loose FKs are not enforced.** Nothing stops a rollup row outliving the `people.*` row it points at between builds. Join, don't assume.

## Counting traps

**A weight is not a count.** Edge weight is a decayed, windowed sum — a member who came fifty times two years ago can weigh less than one who came four times last week. If you want visits, count `bookings.attendance`.

**Weights are not comparable across edge classes.** Each class uses its own half-life, MM is additionally scaled by `α_peer`, and the facility leg of ML is scaled by `α_facility`. "This member's ML weight is bigger than their MS weight" says nothing on its own — that comparison is exactly what the resilience formula exists to do properly.

**Backfill twins can double-count.** The same logical attendance row can exist as both a live and a backfill copy. The signal extractor applies a live-wins filter; **the edge builders read the base tables and do not**. On an org carrying both copies, that attendance contributes twice to MS/MM/ML weight, and inflates the computed class size that the MM cap is checked against. Check before trusting absolute weights:

```sql
SELECT source, source_is_backfill, count(*)
FROM bookings.attendance
GROUP BY 1, 2 ORDER BY 1, 2;
```

**`member_resilience` is per (member, location).** A member attending two locations has two rows with two different quadrants. Never average them; the member's exposure is the **minimum** R̃ — their most brittle attachment, which is what `at_risk_threshold` fires on.

**`primary_member_count` counts primaries, not students.** A teacher with 200 members but only 12 who rank them first has `primary_member_count = 12` and `total_member_edges = 200`. The first number is defection exposure; the second is reach. Reporting one as the other misstates the risk in both directions.

**High `staff_concentration_pct` is not a compliment or a complaint.** It is one number wearing two hats: a strong single-coach bond, and single-coach dependence. Say which one you mean.

**Empty ≠ no relationships.** Every rollup is DELETE-and-rebuilt, so an org that has never had a graph build returns empty from every graph tool. That is "not computed", not "no connections". Check first:

```sql
SELECT 'member_affinity' t, max(computed_at) FROM graph.member_affinity
UNION ALL SELECT 'member_resilience', max(computed_at) FROM graph.member_resilience
UNION ALL SELECT 'location_resilience', max(computed_at) FROM graph.location_resilience
UNION ALL SELECT 'staff_concentration', max(computed_at) FROM graph.staff_concentration;
```

A `computed_at` older than \~26 hours means the nightly sweep didn't run — `get_system_status` says so in plain language.

**Tier without a trend degrades quietly.** `connection_score` exists for MS pairs only, so ML/SL/SS edges can never be `warming` or `cooling` — they resolve to `strong`/`cold`/`lapsed` by recency alone. An ML edge that is "strong" has not been shown to be improving.

**Quadrants are not the at-risk list.** Said once more because it is the mistake that keeps recurring: use `list_at_risk_members` for who is lapsing. `list_quadrant_members` answers "who is structurally exposed", which is a different set of people and a different conversation. And a third, now that it exists: `list_member_status_changes` answers "who has *already* changed state" — someone the sweep flipped to inactive has left the at-risk board (it filters `status = 'active'`) and shows up here instead.

**A status change is not activity.** Lifecycle signals carry `weight = 0` and are excluded from `graph.node.activity_count_30d/_90d` and `last_activity_at`. Counting them would make a member going inactive read as *more* active on the day they left. If you aggregate `graph.signal` yourself, exclude `status_changed`, `plan_started` and `plan_cancelled` or you will reproduce that paradox.

**Baselines are not changes.** Every member has one `is_baseline = true` row per dimension in `people.member_status_event` recording what Kula first observed. They are deliberately *not* projected into `graph.signal`, but a direct query over the table that forgets `NOT is_baseline` reports the entire membership as having changed on the day the feature shipped.

## Questions this layer can and can't answer

**Can answer well**

* Who is a member's primary coach, and how concentrated they are on them
* Which coaches carry the most member relationships — and who leaves with them
* Which members would lose their strongest connection if a given coach left a location
* Where a teacher's relationships are decaying, bucketed by tier with a 30-day trend
* Whether a location can absorb a departure operationally (cover depth, single points of failure)
* How members at a location distribute across the four connection-strength quadrants
* Which members are structurally exposed — narrow routine, no contract, one coach
* The open attention queue: drift, softening cadence, missed second visit, stale pause, milestone
* Who changed membership state recently, and whether that was the vendor's own claim or a Kula derivation *(live-forward only — see the note on score history)*

**Cannot answer**

* Anything about revenue, LTV or spend *(money makes signals, never edges)*
* Anything about 1:1 / PT relationships *(no source fills `bookings.appointment`)*
* Who referred whom, who is related to whom, who mentors whom *(never written)*
* Whether two members are actually friends *(co-attendance is the claim; friendship is not)*
* How any score has moved over time *(no score history beyond the 30-day delta — member **status** history is a separate, canonical thing that does exist; see above)*
* Who is about to churn *(structural exposure ≠ prediction — that's the at-risk layer)*
* Anything for a studio with no instructor on its sessions, or no location rows

**How to phrase the gap.** "Your graph is built from class attendance, so it can see coach relationships but not your PT work — no connected system gives us appointment data." Not: "your relationship data is incomplete."

## Recipes — what works well

### One member's relationships → `get_member_context`

Core facts, per-location resilience with its A and C components and quadrant, plus the affinity rollup, all scope- and purpose-filtered in one call. Prefer it to three separate table reads.

### Who leaves with a coach → `simulate_departure`

Takes `staff_id` + `location_id`, returns the members who lean on that coach, weakest remaining connection first. Reads precomputed weights — no write, no recompute. Pair it with `get_location_context` for whether the *location* can cover the classes.

### A teacher review → `get_staff_relationship_review`

The only tool that exposes the tier distribution *and* per-member trend. The `summary` field carries the full distribution even when the member list is limit-capped — quote the summary, not the truncated list.

### The exposed tail at a location → `get_location_context` → `list_quadrant_members`

Aggregate first, then drill in with `quadrant: 'specialist_nomad'` and a `max_resilience` ceiling. Under analysis purposes members come back anonymised; use `action_board` only when someone will actually act.

### The attention queue → `get_open_signals`

Open rows only (`resolved_at IS NULL`), most severe first. Six types, and they partition deliberately: a paused member fires `pause_drift`, never `drift`, so nobody gets chased twice for the same silence.

### Raw edges → `get_entity_edges`, then SQL

```sql
-- this studio's edge inventory: is the graph actually built, and of what?
SELECT edge_class, kind, tier, count(*), round(avg(weight), 2) AS avg_w
FROM graph.edge
WHERE status = 'active'
GROUP BY 1, 2, 3 ORDER BY 1, 2, 3;
```

An inventory with no `MM` rows means the class-size cap is excluding everything; no `ML` means sessions carry no `location_id`; no `MS` means sessions carry no instructor. All three are input problems, not graph bugs.

```sql
-- the most brittle attachment per member (never average across locations)
SELECT DISTINCT ON (person_id)
       person_id, location_id, vuln_quadrant, resilience_constrained
FROM graph.member_resilience
ORDER BY person_id, resilience_constrained ASC;
```

### What doesn't work

`get_retention_curve`, `get_member_payments` and `get_cac_by_cohort` are not graph tools — they read commerce and marketing, and the graph has no opinion about them. And on an org whose only connector is a payment system (Stripe-only, say), **every graph tool correctly returns empty**: no attendance means no edges, and no edges means no relationships to score. That is the right answer, not a fault to investigate.

## Where this lives in the code

| Concern                                             | Path                                                                                   |
| --------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Member status/plan transition log (table + trigger) | `services/intelligence/migrations/postgres/org/100_people/011_member_status_event.sql` |
| Build orchestration + MS/MM edges                   | `services/intelligence/internal/graph/builder.go`                                      |
| ML/SL/SS edges, classification, resilience rollups  | `services/intelligence/internal/graph/builder_tripartite.go`                           |
| Connection scores + edge tiers                      | `services/intelligence/internal/graph/builder_decay.go`                                |
| Signal extraction + node activity                   | `services/intelligence/internal/graph/builder_signals.go`                              |
| Risk-signal detectors                               | `services/intelligence/internal/graph/signals.go`                                      |
| Tuning parameters (shape only — values injected)    | `services/intelligence/internal/graph/params.go`                                       |
| Tables                                              | `services/intelligence/migrations/postgres/org/800_graph/`                             |
| Purpose-scoped AI tools                             | `services/intelligence/internal/tools/graph_context.go`, `graph.go`                    |
| Freshness + build runner                            | `services/intelligence/internal/admin/graphbuild/`                                     |
| Operator console explorer                           | `services/intelligence/internal/admin/httpapi/stats_graph_network.go`                  |
| Tool reference for AI clients                       | [Graph & relationship tools](/for-developers/graph)                                    |


# What skills are

Saved, repeatable playbooks the AI can run on your studio — the ones we ship, and the ones you build yourself.

A **skill** is a saved, repeatable playbook the AI can run on your studio. Instead of re-typing a multi-step analysis every time, you (or anyone on your team) just ask for it by name and the AI runs the whole thing the same way every time.

Think of a skill as a recipe. *"Find members at risk of leaving"* isn't one question — it's a series of steps (pull recent attendance, compare to each member's normal pattern, rank by how far they've slipped, draft the outreach). A skill bundles those steps so they run consistently and you don't have to remember them.

## Three kinds of skill

* **Free built-in skills** — a curated library we ship and keep current, covering the questions most studios ask. Included with every workspace. See [The built-in library](/skills/library).
* **Skills you build yourself** — playbooks you create for the way *your* studio works, and **share with your team** so everyone's AI runs them the same way. Listing them on the marketplace for other studios is rolling out over time. See [Build your own & share with your team](/skills/build-your-own).
* **Skills you get from the marketplace** — add-ons you choose for your studio, grouped into collections you can browse and add, with more arriving over time.

All three live in one place: your **Skills** page in the operator app ([console.kula.digital](https://console.kula.digital)) — a marketplace and builder where you browse what's included, add paid skills, and author your own.

## Where skills show up

Skills appear inside your AI assistant. In Claude you'll find them as ready prompts you can pick, and you can always just ask for one in plain English:

> *"Run the at-risk members skill for the last 30 days."*

Because skills travel with your studio (not with one assistant), the same skill works whether you ask in Claude today or another assistant tomorrow — and everyone connected to your studio sees the same set.

## Why they're worth it

* **Consistency.** The analysis runs the same way every time, so you're comparing like with like week to week.
* **Speed.** One ask instead of ten.
* **Shared know-how.** The best way you've found to look at your studio becomes something your whole team can run — it doesn't live only in your head.

## Next

* [The built-in library](/skills/library) — what ships free, and how to run one.
* [Build your own & share with your team](/skills/build-your-own) — turn your best questions into a reusable skill.


# The built-in library

The free skills we ship with every studio, and how to run one.

Every studio starts with a curated set of **free** skills we build and keep up to date. You don't install them — they're already there the moment your data is connected, and you'll find them in your **Skills** page.

## What ships free today

* **At-Risk Members** — finds members whose attendance has quietly slipped and ranks who's worth a personal check-in this week.
* **Retention Cohorts** — for your recurring (subscription) memberships, tracks how each join cohort holds on over time, calls out where the drop-off cliffs are, and renders it as an interactive retention report you can open and explore.
* **Data Health** — tells you at a glance whether your studio's data is current, which connectors are behind, and what to do about it — so you can trust the answers the other skills give you.
* **Eval Runner** — a behind-the-scenes skill that checks the quality of skills against known examples. Most owners never touch it directly; it's what keeps the others honest.

We add to and improve this free library over time — improvements arrive automatically, no action needed from you.

## How to run one

Just ask your AI assistant for it in plain English:

> *"Run the at-risk members skill for the last 30 days and draft outreach for the top five."*

> *"Show me membership retention cohorts by join month for the last year."*

You can also pick a skill from the list of ready prompts your assistant shows. Either way, answer any questions it asks along the way, and read what comes back.

## Make them yours

If you find yourself tweaking a built-in skill the same way every time — a particular time window, your own definition of "at risk", your studio's language — you can save *your* version. See [Build your own & share with your team](/skills/build-your-own).


# Build your own & share with your team

Turn the way you analyse your studio into a reusable skill, then publish it so your whole team's AI runs it the same way.

The best way you've found to look at your studio shouldn't live only in your head. Turn it into a **skill** — a saved playbook — and **share it with your team**, so everyone connected to your studio runs it the same way.

You don't need to write code. You describe what you want; the AI writes the skill; you review and publish it.

## Build a skill in three steps

**1. Get the analysis right once.** Work through the questions with your AI assistant until the answer is exactly what you want — the right time window, your own definition of terms, your studio's language.

**2. Ask the AI to save it as a skill.** When you're happy, say:

> *"Turn this into a reusable skill called 'Monday Check-in' so I can run it every week."*

The AI captures the steps and saves the skill to your studio. New skills land as a **draft** first, so nothing goes live to your team until you've looked it over.

**3. Review and publish.** Read the draft, run it once to confirm it does what you meant, then publish it. From that moment it's available to **everyone connected to your studio**.

## Sharing with your team

A published skill is **org-wide**: every AI assistant connected to your studio — yours, your manager's, a coach's — sees the same skill and runs it the same way. You don't send anything around; publishing *is* the sharing.

This is how a studio builds its own playbook: your "Monday Check-in", your "End-of-month review", your "New member 30-day follow-up" — each one captured once and run by anyone.

You build and manage all of this from your **Skills** page in the operator app — the same marketplace where you add built-in and paid skills. Sharing a skill *beyond* your own studio — listing it for other operators on the marketplace — is rolling out over time.

## Keeping skills good

* **Test before you trust.** Run a new skill on a period you already understand, and check the answer matches what you know to be true.
* **Save a few examples.** You can attach known questions-and-answers to a skill so the AI can check itself against them over time. This is the same quality-check the built-in **Eval Runner** uses.
* **Retire what you outgrow.** If a skill stops being useful, mark it deprecated — it stays in the record but drops out of everyday use.

## Want to go deeper?

Skills can do a lot more — load reference notes, follow a strict procedure, build on each other, and chain into a multi-step skill set. If you (or a technical teammate) want to author skills in depth, the [developer tools reference](/for-developers/tools) documents the skill tools in full.

## Related

* [What skills are](/skills/skills) · [The built-in library](/skills/library)
* [Your weekly rhythm](/get-started/your-rhythm) — a natural home for your own skills.


# Helping a studio go faster

For consultants — how to set a studio up, run the first diagnostic, and hand control back. Optional by design; the owner can do all of this themselves.

This section is for **consultants** — advisors who help studio owners get up and running with Kula Intelligence.

A quick framing first: **everything here, the owner can do themselves.** Kula is built so a non-technical owner can set up, ask their first questions, and keep going without help (that's the [owner guide](/)). Your value isn't that you're *required* — it's that you make it **faster and clearer**. You do the setup in minutes, work through the first questions alongside them, frame the early wins, and hand back something they can run on their own.

## The shape of an engagement

Two steps, then you're out:

1. [**Set up on their behalf**](/for-consultants/set-up-on-their-behalf) — connect the studio's data using a single-use connect link, so the owner doesn't have to wrangle keys, then work through the first questions *with* the owner and turn what you find into a short list of early wins.
2. [**Hand off cleanly**](/for-consultants/hand-off) — give the owner the keys, revoke your own access, and leave them with a simple weekly rhythm.

## The owner stays in control throughout

* The studio's data lives in **its own private database**, tied to the owner's account — not yours. See [Security & data handling](/trust-and-legal/security).
* Any access you're given to help is **revocable**, and you revoke it at hand-off. The owner is never dependent on you to keep using Kula.
* You never see more than the owner's chosen **permission level** allows — the same limits apply to you as to them. See [Permission levels](/connect-claude-and-access/scopes).

## Working with more than one studio

Each studio is completely separate — its own database, its own account, its own access. There's no shared view across studios, by design. So working with several studios just means repeating this clean set-up → diagnose → hand-off cycle for each, with no risk of one client's data touching another's.

## Start here

→ [Set up on their behalf](/for-consultants/set-up-on-their-behalf)


# Set up on their behalf

Use a single-use connect link to set a studio up without the owner having to handle keys — while their data stays theirs.

The owner *can* do their own setup. But if you're helping several studios, or the owner would rather you handle it, you can do the connecting for them using a **single-use connect link** — without ever needing their passwords.

## How the connect link works

1. The owner creates their Kula account and, from their admin, mints a **single-use connect link** scoped to a **permission level** they choose.
2. They share that link with you — usually by having Kula **email it to you** straight from that screen.
3. Connect, either way:

   * **One click.** The invite email (and the connect page it links to) has an **Open in Claude** button. It opens Claude with the **Add custom connector** dialog already filled in — connector name and Remote MCP server URL — so you just review, **Add → Connect**, and approve.
   * **By hand.** The same screen shows the link in full. In your AI assistant, **Add custom connector** and paste it as the **Remote MCP server URL** (leave Advanced settings blank), then **Add → Connect** and follow the sign-in/approve prompts.

   Either way your assistant connects to *their* studio scoped to the link's permission level — no password of theirs changes hands.

The link is **single-use** and **time-limited**: once you've used it, it's spent, and it expires on its own if unused. The owner can revoke it at any time. This is the same secure front door the product uses everywhere — you never hold a long-lived secret, and the owner can cut access in one click.

## Connecting the studio's data sources

With access in hand, connect the studio's sources the same way the owner would — each source's page has the steps:

* [Stripe](/your-data-sources/stripe) · [Wix](/your-data-sources/wix) · [Mindbody](/your-data-sources/mindbody) · [Xero](/your-data-sources/xero)
* [Meta](/your-data-sources/meta) · [Google Analytics 4](/your-data-sources/ga4) · [CSV & ClassPass](/your-data-sources/csv)

A few things worth doing well, since you're the one setting up:

* **Start with the studio's spine** — usually their booking platform (Mindbody or Wix) plus payments (Stripe). That alone unlocks most questions.
* **Use the right keys.** Where a provider offers a read-only or restricted key (Stripe, Wix, Meta), use it. The connector pages call out which.
* **Kick off the import and let it run.** A big studio takes 20–30 minutes to import. Set it going, then move on to the diagnostic prep.

## Keep the owner's permission level sensible

When you connect, you're working at the **permission level** on the link. **Operations** is the right default — names visible, contact and payment details hidden. Only step up if a specific task needs it. See [Permission levels](/connect-claude-and-access/scopes).

## Next

Once the data's importing, work through the first questions *with* the owner, frame a short list of early wins, then [hand off cleanly](/for-consultants/hand-off).


# Hand off cleanly

Leave the owner self-sufficient — a short leave-behind, the keys in their hands, and your own access revoked.

A good engagement ends with the owner able to run Kula **without you**. This page is the clean exit: give them the keys, leave them a simple rhythm, and revoke your own access.

## 1. Make sure the owner can get in on their own

* Confirm the owner can sign in to their Kula account and reach their dashboard.
* Confirm their **own** AI assistant is connected — not just yours. If you set up under a single-use connect link, have the owner connect their own assistant now (it takes two minutes — [Connect Claude](/connect-claude-and-access/claude)).
* Walk them through asking one real question and getting an answer, so they leave the call having done it themselves.

## 2. Leave a simple rhythm

Hand them [Your weekly rhythm](/get-started/your-rhythm) — it's written for exactly this moment. Point them at:

* The three questions to ask each week.
* The one weekly glance at the **Connectors** page.
* The monthly re-run of the diagnostic.

If you built any custom [skills](/skills/build-your-own) for them, show them how to run those by name, and make sure the skills are **published** to the studio so they survive after your access is gone.

## 3. Revoke your access

This is the step that makes the hand-off real:

* The connect link you used is already narrow and time-limited — but don't leave it dangling. Have the owner **revoke that connector** in **console.kula.digital → Connectors**, so the only access remaining is the owner's own.
* Confirm with the owner that, from here, **they** hold every key. Nothing about their ongoing use should depend on you.

Within about a minute of revoking, your access stops. The owner's data was always in their own private database under their account — revoking simply closes the door you came through.

## 4. Leave the trust story with them

If the owner ever needs to reassure a partner, accountant, or their own clients, point them at:

* [Is my data safe?](/get-started/trust) — the plain-English version.
* [Security & data handling](/trust-and-legal/security) — the detail.

## That's the engagement

Set up → diagnose → hand off. The owner keeps going on their own, and if they bring you back for a quarterly look, you run the same clean cycle again.


# Overview

The technical shape of Kula Intelligence — a remote MCP server over per-studio Postgres, with OAuth and a curated tool surface.

Kula Intelligence is a **remote** [**MCP**](https://modelcontextprotocol.io) **server**. AI clients connect to it, authenticate, and call a curated set of tools that read and (where permitted) write a single studio's operational data.

This section is for developers and integrators — agencies building on the Claude API, technical staff wiring up Claude Code, or anyone who wants the detail under the owner-facing guides.

## The shape

* **No anonymous access.** Every connection carries a **role-scoped credential created in console.kula.digital** — a **connect link** (`/connect/{…}/mcp`, OAuth) for Claude, or an **access token** (bearer) for other clients. Served over **streamable HTTP**.
* **One studio per connect link.** Every request is authenticated, and the credential identifies exactly one studio. Tenancy is enforced by a **database boundary** — each studio's data lives in its own Postgres database, and there is no cross-studio read path. The studio is derived from the credential, never from a tool argument.
* **A curated tool surface, not raw database access.** Clients don't run arbitrary SQL against the engine. They call purpose-built tools (`list_tables`, `execute_query` for read-only SELECTs, `entity_lookup`, `get_member_plan_status`, and so on). Reads are scope-gated; writes go only through guarded, audited tools. See the [tool reference](/for-developers/tools).
* **Skills as prompts.** The skill library is surfaced as **MCP prompts**, so curated playbooks work in any compliant client.

## Authentication, in one line

There is no open, anonymous access — every client connects with a **role-scoped credential created in console.kula.digital**, and which kind depends on the client:

* **Connect link (OAuth)** for clients with a custom-connector flow (Claude): `https://mcp.kula.digital/connect/{…}/mcp`; using it runs the OAuth handshake (discovery, registration, PKCE, sign-in, consent — see [OAuth](/for-developers/oauth)).
* **Access token (bearer)** for other clients: minted in console.kula.digital and presented as `Authorization: Bearer <token>` against `https://mcp.kula.digital/mcp`. See [Connect](/for-developers/connect).

Either way the credential carries its [permission level](/connect-claude-and-access/scopes); the runtime verifies it, derives the studio and level, and routes to that studio's database.

## What it deliberately doesn't do

* No money movement, refunds, or transfers.
* No AI media generation.
* No cross-studio reads — not even for analytics.
* No arbitrary DML/DDL. `execute_query` is SELECT/`WITH`-only; mutations go through specific, audited tools.

## Where to go next

* [Connect — API, Code, Cursor, Lovable](/for-developers/connect) — wire up a client.
* [Tool reference](/for-developers/tools) — every tool, with read-only/destructive flags.
* [Schema reference](/for-developers/schema) — the tables and columns behind the freeform query tools.
* [OAuth](/for-developers/oauth) — discovery, DCR, PKCE, scopes, audience binding.
* [Changelog & versioning](/for-developers/changelog) — how the surface evolves.


# Connect — API, Code, Cursor, Lovable

Connect non-OAuth AI clients with a role-scoped access token minted in console.kula.digital, which generates the ready-to-paste config for each platform.

There are two ways to connect, depending on what your client supports — and **both** credentials are role-scoped and created in **console.kula.digital**, so access stays under your control. There's no open, anonymous endpoint.

| Your client                                             | Method                                                                                         | See                                                 |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| Claude Desktop / claude.ai                              | **Custom connector (OAuth)** with a connect link — one click from an invite, or pasted by hand | [Connect Claude](/connect-claude-and-access/claude) |
| ChatGPT (Plugins / MCP Servers)                         | **Custom MCP server (OAuth)** with the same connect link                                       | [Connect Claude](/connect-claude-and-access/claude) |
| Claude API, Claude Code, Cursor, Lovable, custom agents | **Access token (bearer)**                                                                      | this page                                           |

This page covers the **access-token** method, for any client that connects with a bearer token rather than the OAuth custom-connector flow.

## Get an access token

1. Sign in to **console.kula.digital** and open **Tokens** → **Mint a token**. (**Connectors → New invite** is the other path — that mints an OAuth *connect link* for the Claude/ChatGPT custom-connector flow, not a bearer token.)
2. Choose the **permission level** for the integration — see [permission levels](/connect-claude-and-access/scopes).
3. Pick your **platform**. The app generates the **ready-to-paste connection config for that platform** (and the token itself, shown once). Copy it.

The token is a role-scoped credential, exactly like a connect link — it just travels in an `Authorization: Bearer` header instead of in the URL. The endpoint it points at is:

```
https://mcp.kula.digital/mcp        (Authorization: Bearer <token>)
```

> Use the per-platform config the app gives you — the snippets below show the shape so you know what to expect, but the app fills in your token and the exact format for your client.

## Claude API (programmatic)

Pass the token as `authorization_token` on the MCP server entry.

```python
from anthropic import Anthropic

client = Anthropic()

response = client.messages.create(
    model="claude-opus-4-8",  # or whichever Claude model is current when you read this
    max_tokens=4096,
    mcp_servers=[{
        "type": "url",
        "url": "https://mcp.kula.digital/mcp",
        "name": "kula-intelligence",
        "authorization_token": "YOUR_TOKEN_HERE",
    }],
    messages=[{
        "role": "user",
        "content": "Which instructor has the highest affinity with member M123?",
    }],
)

print(response.content)
```

## Claude Code (CLI)

```bash
claude mcp add --transport http kula-intelligence https://mcp.kula.digital/mcp \
  --header "Authorization: Bearer YOUR_TOKEN_HERE"
```

Verify with `claude mcp list`, then use your studio data in any session.

## Cursor

Add an MCP server in Cursor's settings with the URL above and an `Authorization: Bearer YOUR_TOKEN_HERE` header. Create the token at the **operations** or **admin** level for most work on member data — see [permission levels](/connect-claude-and-access/scopes).

## Lovable

Add Kula as an MCP tool source with the URL and bearer header. For dashboards and embeds that show trends without naming individuals, create the token at the **analytics** level so no personal data is ever returned.

## Any other MCP-compatible client

The server speaks standard MCP over streamable HTTP. Point the client at `https://mcp.kula.digital/mcp` with an `Authorization: Bearer <token>` header. The bare endpoint **requires** a valid role-scoped token — without one it returns `401`, so there's no anonymous access.

## Verify the connection

Ask the client:

> *"List the tools you can use from Kula Intelligence."*

You should get the full [tool list](/for-developers/tools) back — scoped to the token's level.

## Good practice

* **One token per integration**, so usage and revocation are independent.
* **Lowest workable level** — see [permission levels](/connect-claude-and-access/scopes).
* **Never commit a token.** Store it in your platform's secret manager, and revoke it from console.kula.digital if it leaks.


# 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), and Cursor, Lovable and other clients are covered in [Connect](/for-developers/connect). **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.


# Tool reference

Every tool the Kula Intelligence MCP server exposes, with read-only/destructive flags and the minimum permission level.

This is the catalogue of tools the MCP server exposes. Each is annotated **Read** (read-only) or **Write** / **Destructive**, and the minimum [permission level](/connect-claude-and-access/scopes) it needs.

The server is the source of truth — ask a connected client to list its tools to see exactly what's advertised to you. Tools you call without a high enough permission level return a clear `403` and reveal nothing.

> **Conventions.** Read-only tools carry `readOnlyHint`. Tools that modify or delete data carry `destructiveHint`. Creating or saving workspace data (saved views, notes, skills, artifacts, business context) requires at least the `operations` level; deleting them and other destructive/admin actions require `admin`. **No tool changes member, booking or payment records at any level** — those are read-only to the assistant. The exception is the studio's shared **memory** — the assistant's own scratchpad, not business data — where creating and editing notes is available at **any** level (see [Memory](#memory-shared-per-studio)).

## Querying your data

| Tool                     | Kind | Min level  | What it does                                                                                              |
| ------------------------ | ---- | ---------- | --------------------------------------------------------------------------------------------------------- |
| `list_tables`            | Read | analytics  | List the tables available in your studio's database.                                                      |
| `get_table_schema`       | Read | analytics  | Column definitions for a table.                                                                           |
| `execute_query`          | Read | analytics  | Run a read-only `SELECT`/`WITH` query. No writes. **Schema:** [schema reference](/for-developers/schema). |
| `build_query`            | Read | analytics  | Help compose a parameterised `SELECT`. **Schema:** [schema reference](/for-developers/schema).            |
| `entity_lookup`          | Read | analytics  | Resolve a name or email to a canonical entity id (member, location, org).                                 |
| `get_member_payments`    | Read | operations | A member's sales/payment history (honours scoping).                                                       |
| `get_member_plan_status` | Read | operations | A member's plan/membership status.                                                                        |

## Business context & catalogue

| Tool                      | Kind  | Min level  | What it does                                                                                                                                                          |
| ------------------------- | ----- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get_semantic_catalogue`  | Read  | analytics  | The data dictionary plus operator-authored business context for this studio.                                                                                          |
| `get_business_context`    | Read  | analytics  | The studio's profile, classes, demographics, goals, brand voice, people, and pricing.                                                                                 |
| `update_business_context` | Write | operations | Saves a confirmed answer into one business-context section. Merges into the section's existing content — only the keys you send are changed, nothing else is touched. |

## Graph & signals

The relationship graph (who is connected to whom and how healthy those connections are) plus the separate attention-signal queue. See [**Graph & relationship tools**](/for-developers/graph) for the full model (edges, connection tiers, connection strength, quadrants, signals) and per-tool detail.

Summary rollups:

| Tool                         | Kind | Min level  | What it does                                                                                                                                                                                                                       |
| ---------------------------- | ---- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get_member_affinity`        | Read | operations | A member's primary coach, top-3 staff, and affinity concentration.                                                                                                                                                                 |
| `get_staff_concentration`    | Read | operations | Per-staff routing value: how many members' connections are anchored on this coach.                                                                                                                                                 |
| `get_open_signals`           | Read | operations | The open attention-signal queue (drift, freq\_softening, pause\_drift, at\_risk\_threshold, missed\_second\_visit, milestone).                                                                                                     |
| `list_member_status_changes` | Read | operations | Who changed status, membership type, plan or suspension state in the last N days, with the reason where one is knowable. Live-forward only — the log begins when capture started, because no source system records status history. |
| `get_cac_by_cohort`          | Read | analytics  | Customer-acquisition cost broken down by cohort.                                                                                                                                                                                   |

Relationship context — each takes a required `purpose` that scopes the response (analysis purposes anonymise; service-delivery purposes withhold the connection-strength analytics). Denials return an empty result, not an error:

| Tool                    | Kind | Min level  | What it does                                                                           |
| ----------------------- | ---- | ---------- | -------------------------------------------------------------------------------------- |
| `get_member_context`    | Read | operations | One member's connection strength (resilience + quadrant) and affinity routing.         |
| `get_staff_context`     | Read | operations | A staff member's profile, concentration and locations.                                 |
| `get_location_context`  | Read | operations | A location's operational resilience and aggregate connection-strength quadrant spread. |
| `list_quadrant_members` | Read | operations | The members in a connection-strength quadrant at a location, weakest-connection first. |
| `simulate_departure`    | Read | operations | Which members would lose their strongest connection if a staff member left a location. |
| `get_entity_edges`      | Read | operations | The graph edges around a member/staff/location.                                        |

## Views

| Tool            | Kind        | Min level  | What it does                |
| --------------- | ----------- | ---------- | --------------------------- |
| `list_views`    | Read        | analytics  | List saved views.           |
| `describe_view` | Read        | analytics  | A view's SQL and metadata.  |
| `create_view`   | Write       | operations | Create a named, saved view. |
| `drop_view`     | Destructive | admin      | Delete a view.              |

## Skills

| Tool               | Kind | Min level | What it does                                                |
| ------------------ | ---- | --------- | ----------------------------------------------------------- |
| `list_skills`      | Read | analytics | List available skills (built-in + your own).                |
| `get_skill`        | Read | analytics | Fetch a skill's full definition (may be operator-authored). |
| `list_skill_files` | Read | analytics | List a skill's bundled files.                               |
| `get_skill_file`   | Read | analytics | Fetch one bundled skill file.                               |

> Skill *authoring* via the connector (creating or deprecating skills from a chat session) is not currently exposed — skills are authored and managed in the Kula console. Connector-side authoring returns in v2.

## Memory (shared, per-studio)

| Tool            | Kind        | Min level  | What it does                                 |
| --------------- | ----------- | ---------- | -------------------------------------------- |
| `memory_view`   | Read        | any        | Read a note from the studio's shared memory. |
| `memory_create` | Write       | any        | Create a memory note.                        |
| `memory_edit`   | Write       | any        | Edit a memory note.                          |
| `memory_rename` | Write       | operations | Rename a memory note.                        |
| `memory_delete` | Destructive | admin      | Delete a memory note.                        |

> This is the **studio's** shared memory, stored in this connector's database — separate from the AI client's own conversation memory. It's the assistant's scratchpad, not studio business data, so **storing** a note — `memory_create` / `memory_edit` — works at any level, including a read-only login that can't change anything else. Renaming needs `operations`; deleting (irreversible) needs `admin`.

## Knowledge wiki

| Tool               | Kind  | Min level  | What it does                         |
| ------------------ | ----- | ---------- | ------------------------------------ |
| `search_knowledge` | Read  | operations | Search the studio's knowledge notes. |
| `get_knowledge`    | Read  | operations | Fetch a knowledge note.              |
| `add_note`         | Write | operations | Add a knowledge note.                |
| `link_knowledge`   | Write | operations | Link knowledge notes/entities.       |

## Artifacts

| Tool              | Kind  | Min level  | What it does                                                |
| ----------------- | ----- | ---------- | ----------------------------------------------------------- |
| `create_artifact` | Write | operations | Save a report/export/summary that persists beyond the chat. |

## Ingest audit (raw records)

| Tool               | Kind  | Min level | What it does                                 |
| ------------------ | ----- | --------- | -------------------------------------------- |
| `list_raw_records` | Read  | admin     | Query the raw ingest log.                    |
| `get_raw_record`   | Read  | admin     | Fetch one raw ingest payload.                |
| `record_raw`       | Write | admin     | Write a raw ingest payload (ingest tooling). |

## What you won't find

No tools move money, issue refunds, generate media, or read across studios. Those capabilities are deliberately absent — see [Security & data handling](/trust-and-legal/security).


# Schema reference

The tables and columns behind the freeform query tools — schema families, the canonical column pattern, and how PII redaction works per permission level.

This is the data model the freeform query tools (`execute_query`, `build_query`) read. It documents the **schema families** and the shape of the core tables. For exact, live column lists, use `list_tables` and `get_table_schema` against your own studio — they are the source of truth, and this page mirrors them.

> Every studio's database carries the same shape, populated from whichever [connectors](/your-data-sources/connectors) it has. A table is empty, not absent, if you haven't connected a source that feeds it.

## How the model is organised

Tables live under a small set of schemas, all on the default `search_path`:

| Schema       | Holds                                                                                                                                              |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `people`     | Members, staff, companies, locations, identity links                                                                                               |
| `commerce`   | Sales, payments, refunds, plans, products                                                                                                          |
| `bookings`   | Class sessions, attendance, appointments, facility entries                                                                                         |
| `accounting` | Chart of accounts, journals, entries, invoice lines                                                                                                |
| `marketing`  | Ad accounts, campaigns, daily spend, attribution links                                                                                             |
| `comms`      | Messages and attachments                                                                                                                           |
| `graph`      | Relationship edges/nodes, affinity, member & location connection strength (resilience), connection-strength quadrants, attention signals (derived) |
| `compliance` | Compliance records                                                                                                                                 |
| `ingest`     | `raw_record` — the verbatim landing zone before transform                                                                                          |
| `mcp`        | The AI-collaboration layer (skills, memory, knowledge, artifacts, business context) and ingest bookkeeping                                         |

## The canonical column pattern

Most fact tables are **source-stamped and event-sourced**, so they share a common envelope of columns:

| Column                      | Meaning                                                                   |
| --------------------------- | ------------------------------------------------------------------------- |
| `id`                        | Canonical UUID for the row.                                               |
| `source`                    | The connector that produced it (e.g. `com.stripe`, `com.wix`).            |
| `source_external_id`        | The provider's own id for the record.                                     |
| `site_id`                   | The location/sub-tenant within the studio, if any.                        |
| `occurred_at`               | When the event actually happened (not when we received it).               |
| `source_extras`             | `jsonb` — provider fields we kept verbatim but didn't promote to columns. |
| `source_modified_at`        | When the provider last changed it.                                        |
| `created_at` / `updated_at` | When Kula first/last wrote the row.                                       |
| `ingest_event_id`           | The idempotency key from the ingest envelope.                             |

This means you can always trace a canonical row back to the exact provider record it came from.

## PII redaction and the `_guarded` companions

Many base tables have a **`_guarded` companion** (for example, `commerce.sale` and `commerce.sale_guarded`). The guarded view applies **redaction based on your token's** [**permission level**](/connect-claude-and-access/scopes) — hiding member names, contact details, or payment fragments you aren't entitled to. Redaction happens server-side, so a lower level can't be tricked into revealing more.

Prefer the guarded companion (or the purpose-built read tools like `get_member_payments` and `get_member_plan_status`, which honour scoping automatically) when you want redaction handled for you.

> **Hide the books.** A connector can also be set to withhold the whole `accounting` schema — chart of accounts, journals, entries, invoice lines — regardless of its permission level. A query against any `accounting` table (including its `_guarded` view) then returns a clear denial, while `commerce` (sales, payments, refunds) stays readable. This is the orthogonal "hide the books" restriction — see [Permission levels](/connect-claude-and-access/scopes).

## Core tables

### `people.member`

One row per member. Key columns:

`id`, `first_name`, `last_name`, `display_name`, `email`, `mobile_phone`, `status`, `membership_type`, `plan_name`, `current_plan_id`, `is_booking_suspended`, `suspension_start_date`, `suspension_end_date`, `member_since`, `first_session_date`, `home_location_id`, `home_location_name` — plus the canonical envelope columns.

> `email`, `mobile_phone`, and the suspension dates redact below the `admin` level; names redact below `operations`. See [permission levels](/connect-claude-and-access/scopes).

### `people.member_status_event`

Append-only log of member **status** and **membership/plan** transitions. No booking or payment system emits a status-change event — each exposes only a current-state field — so Kula synthesises the event by detecting the change itself. This is the only place that answers "when did this member churn" or "was that inactive flag the vendor's or ours". Key columns:

`id`, `member_id`, `source`, `source_external_id`, `dimension` (`status` | `membership_type` | `plan` | `suspension`), `from_value`, `to_value`, `occurred_at`, `detected_at`, `is_baseline`, `reason`, `reason_source` (`rule` | `vendor` | `operator` | `baseline` | `unknown`), `actor`, `detail`.

> Two rules, or every number is wrong. **(1)** The log is live-forward only — it starts when capture began on the studio and cannot be backfilled — and each member has one `is_baseline = true` origin row per dimension recording what we first saw. Filter `NOT is_baseline` when counting changes. **(2)** It is one row per `people.member` **row**, not per human: group by `COALESCE(canonical_member_id, id)` or cross-source duplicates count several times. `list_member_status_changes` does both for you.

### `commerce.sale`

One row per sale line (refunds are negative companions in `commerce.refund`). Key columns:

`id`, `occurred_at`, `amount`, `tax`, `total`, `discount`, `currency`, `quantity`, `item_type`, `item_id`, `item_name`, `item_category`, `description`, `member_id`, `staff_id`, `location_id`, `payment_method`, `payment_status`, `payment_provider`, `provider_transaction_id`, `transaction_id`, `transaction_status`, `payment_last4`, `reason_code`, `billing_email`, `is_recurring`, `installments` — plus the envelope columns.

> Amounts and classification are visible at `operations`; `billing_email` at `admin`; `payment_last4` and provider transaction ids only at `full`.

### `bookings.attendance`

One row per booking/visit. Key columns:

`id`, `class_session_id`, `member_id`, `occurred_at`, `booking_status`, `booked_at`, `checked_in_at`, `cancelled_at`, `cancellation_window_minutes`, `sale_source_external_id`, `plan_source_external_id` — plus the envelope columns.

> `sale_source_external_id` and `plan_source_external_id` are what the diagnostic uses to connect each attendance to the plan and sale behind it.

## The `graph` schema (derived)

The `graph.*` tables are a **derived** layer, rebuilt from `people`, `bookings` and `commerce` — they hold no source data of their own. See [Graph & relationship tools](/for-developers/graph) for what the model means; the tables below are what you query directly. Person/location keys are **loose foreign keys** to `people.member.id` / `people.location.id`.

### `graph.member_resilience`

One row per (member, location). The connection-strength model.

`person_id`, `location_id`, `w_ml`, `w_peers`, `max_w_ms`, `resilience`, `activity_affinity`, `contract_commitment`, `resilience_constrained`, `vuln_quadrant` (`resilient_enthusiast` | `constrained_captive` | `flexible_explorer` | `specialist_nomad`), `computed_at`.

> `resilience_constrained` is the headline connection-strength score; `vuln_quadrant` is the activity-affinity × contract-commitment taxonomy.

### `graph.member_affinity`

One row per member — the routing rollup.

`person_id`, `primary_staff_id`, `top3_staff_ids`, `staff_concentration_pct`, `primary_edge_weight`, `primary_edge_tier`, `computed_at`.

### `graph.staff_concentration`

One row per staff member. `primary_member_count` is routing value — how many members' connections are anchored on this staff member.

`staff_id`, `primary_member_count`, `total_member_edges`, `avg_edge_weight`, `computed_at`.

### `graph.location_resilience`

One row per location — operational resilience.

`location_id`, `ops_resilience`, `cover_depth`, `staff_count`, `single_points_of_failure`, `computed_at`.

### `graph.risk_signal`

The attention-signal queue — the sense layer, distinct from the connection- strength scores above. The open queue is `WHERE resolved_at IS NULL`.

`id`, `person_id`, `node_id`, `signal_type` (`drift` | `freq_softening` | `missed_second_visit` | `milestone` | `pause_drift` | `at_risk_threshold`), `severity` (`low` | `medium` | `high`), `detected_at`, `context` (`jsonb` — supporting facts and routing), `resolved_at`, `resolution`.

### `graph.node` and `graph.edge`

The underlying entities and relationships. `graph.node` carries one row per entity (`entity_type`, `entity_id`, `display_label`); `graph.edge` carries the weighted relationships (`from_node_id`, `to_node_id`, `kind`, `edge_class` MS/ML/SL/MM/SS, `weight`, `tier`, `last_observed_at`, `status`). Prefer the rollups above and `get_entity_edges` for most questions.

## Getting the full, live picture

This page documents the shape; your studio's database is authoritative.

```
list_tables                      → every table/view on your search_path
get_table_schema("commerce.sale") → exact columns, types, and redaction state
get_semantic_catalogue            → human descriptions + PII classification
                                    + your studio's business context
```

Because the guarded views and the read tools enforce scoping, you can query freely with `execute_query` (SELECT/`WITH` only) without having to track the column-level PII map yourself.


# Graph & relationship tools

The relationship graph — member–staff–location edges, connection tiers and connection-strength scores — and the purpose-scoped tools that read who is connected to whom and how healthy those connection

Kula Intelligence derives a **relationship graph** from the attendance and sales data your [connectors](/your-data-sources/connectors) already feed in. It is a read-only analytics layer: no new data, no external calls — just a scored view of who trains with whom, who depends on which coach, and how healthy each of those connections is.

> **What the graph is for.** The graph tells you *who is connected to whom and how healthy that connection is* — the relationship context behind a member or staff member. It is **not a churn-risk predictor**. Spotting members who have started dropping off is the job of the **attention signals** (a separate sense layer that future [Whispers](#attention-signals) will surface) and of the **at-risk-members skill**, both of which read from — but are distinct from — the relationship graph.

This page covers what the graph models, how it scores the strength of each relationship, and the tools that read it. For the raw tables, see the [schema reference](/for-developers/schema).

## What the graph models

The graph is **tripartite** — three entity types (members, staff, locations) connected by five classes of weighted edge:

| Edge class | Between           | Captures                                  |
| ---------- | ----------------- | ----------------------------------------- |
| **MS**     | member ↔ staff    | who a member trains with — the coach bond |
| **ML**     | member ↔ location | attachment to the studio/space itself     |
| **MM**     | member ↔ member   | the peer community (who attends together) |
| **SL**     | staff ↔ location  | where a coach works                       |
| **SS**     | staff ↔ staff     | the cover / co-teach network              |

Edges are **time-decayed** (recent activity counts for more than old) and **windowed** (stale relationships age out), so the graph reflects *current* relationships, not lifetime totals. Each edge class decays on its own half-life and ages out at its own **drop-off window** — a coach bond (member↔staff) fades faster than facility/organisational bonds. Every edge carries a **tier** (`strong`/`warming`/`cooling`/`cold`/`lapsed`) derived from how recently it was last seen plus its 30-day **trend**, so you can read *where* a relationship is decaying and over *what timeframe* — see `get_staff_relationship_review` for the per-teacher breakdown.

## Connection strength: resilience and quadrants

On top of the edges, the graph computes a per-member, per-location **connection-strength** score (stored as `resilience`): how well-anchored a member is beyond any single coach — i.e. how well their relationship with the studio would hold up if their favourite coach left. A member anchored by the location, their peers, and a varied routine has a strong connection; one held only by a single coach bond has a fragile one.

Connection strength is **constraint-modulated** by two factors:

* **Activity affinity (A)** — how broadly the member uses the location (many instructors and classes vs. a single specialist slot).
* **Contract commitment (C)** — the switching cost of their plan (a recurring membership vs. pay-as-you-go).

Together, A and C place each member in one of four **connection-strength quadrants** (the `vuln_quadrant` field):

| Quadrant               | Affinity | Commitment | How to read it                                                     |
| ---------------------- | -------- | ---------- | ------------------------------------------------------------------ |
| `resilient_enthusiast` | high     | high       | Strongest connection — broad routine, committed plan.              |
| `constrained_captive`  | low      | high       | Committed but narrow — a single-coach specialist on a sticky plan. |
| `flexible_explorer`    | high     | low        | Broadly engaged, but low switching cost.                           |
| `specialist_nomad`     | low      | low        | **Weakest connection** — narrow routine, no commitment.            |

> The exact scoring is tuned per deployment and not published. The tools return the resulting scores and quadrant labels so you can act on them; the [`list_quadrant_members`](#list_quadrant_members) tool lists the actual members in any quadrant.

There is also a per-**location** operational resilience: the share of staff who have a cover partner, and the single points of failure (coaches who have none). Read it with [`get_location_context`](#get_location_context).

## Attention signals

Separately from the relationship scores above, the graph also emits an **attention-signal queue** — derived facts that a member may have started dropping off and could need attention now. This is the **sense layer**, distinct from the relationship context: it flags a *change in behaviour*, not the strength of a connection. Read the open queue with [`get_open_signals`](#get_open_signals). Future **Whispers** will surface these to front-line staff, and the separate **at-risk-members skill** turns them into an outreach list — the graph itself only exposes the signals.

| Signal                | Fires when                                                                                                           |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `drift`               | A previously-regular member has gone quiet for a stretch.                                                            |
| `freq_softening`      | Attendance cadence is slowing while they're still showing up — the early warning before drift.                       |
| `missed_second_visit` | They paid, came once, and never returned.                                                                            |
| `pause_drift`         | A paused member isn't resuming.                                                                                      |
| `at_risk_threshold`   | Their constraint-modulated resilience has crossed a low threshold — a structural signal, not just a behavioural gap. |
| `milestone`           | A round-number class count — a positive moment worth celebrating.                                                    |

> **Boundary.** Kula Intelligence *computes and exposes* these signals. It never composes or sends a message — that belongs to the studio's own action layer. Everything on this page is read-only.

## The relationship-context tools

These tools return **pre-scoped relationship context** — richer than a raw table read, and automatically filtered to what your [permission level](/connect-claude-and-access/scopes), your relationship to the entity, and your **declared purpose** allow.

Every call takes a required `purpose` argument, which shapes the response:

* **Analysis purposes** (`retention_analysis`, `departure_simulation`, `cohort_analysis`) **anonymise** members — names become stable pseudonyms and the underlying ids are dropped, so you can study patterns without handling identities.
* **Action purposes** (`action_board`) return real names so an operator can act.
* **Service-delivery purposes** (`whisper_generation`, `pre_class_brief`) **withhold the connection-strength analytics** (the resilience and quadrant scores) — those are for analysis, not front-line delivery.

Other accepted purposes: `ai_coach_session`, `member_self_access`, `staff_self_access`. A denied request returns an **empty result**, never an error that would confirm the entity exists.

| Tool                            | Min level  | What it does                                                                                                                                                                     |
| ------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get_member_context`            | operations | One member's bundle: core facts, per-location connection strength (resilience + quadrant), and affinity routing.                                                                 |
| `get_staff_context`             | operations | A staff member's profile, concentration (members who rely on them) and the locations they work.                                                                                  |
| `get_staff_relationship_review` | operations | A teacher's members bucketed by relationship tier (strong/warming/cooling/cold/lapsed) with each bond's timeframe and trend — the connection-decay breakdown for a staff review. |
| `get_location_context`          | operations | A location's operational resilience and the *aggregate* spread of members across the four connection-strength quadrants.                                                         |
| `list_quadrant_members`         | operations | The members **in** a quadrant at a location — the act-on-it drill-down from the aggregate.                                                                                       |
| `simulate_departure`            | operations | Which members would lose their strongest connection if a given staff member left a location, ordered weakest-connection first.                                                   |
| `get_entity_edges`              | operations | The raw edges around a member/staff/location, with class, weight and recency.                                                                                                    |

### `get_member_context`

One member's full relationship picture. **Parameters:** `member_id` (required), `purpose` (required). Returns the member's core facts, per-location connection strength (the `resilience` score, activity affinity, contract commitment, `vuln_quadrant`), the affinity routing rollup (primary coach, top-3 staff, concentration), and any operator/AI annotations. The connection-strength scores are withheld under service-delivery purposes.

### `get_staff_context`

**Parameters:** `staff_id` (required), `purpose` (required). Returns the staff member's profile, their concentration rollup (how many members rely on them — routing value and how concentrated members' connections are on them), and the locations they work. Heavily restricted: a relationship-bound staff credential may only query itself.

### `get_staff_relationship_review`

The connection-decay breakdown for a teacher (staff) review. **Parameters:** `staff_id` (required), `purpose` (required), `tier` (optional — restrict the member list to one of strong/warming/cooling/cold/lapsed), `limit` (optional, default 200, max 500). Returns the teacher's members grouped by relationship **tier**, and for each member: the member↔staff edge weight, `last_observed_at`, `days_since`, `trend` (rising/flat/falling), and 30-day/90-day/total attendance with this teacher. A `summary` gives the full tier distribution even when the member list is `limit`-capped. The tier is derived from how recently the member last trained with the teacher (relative to the member↔staff half-life and drop-off window) plus the 30-day trend — so "where the relationship is decaying, and over what timeframe" reads straight off the buckets. Same restriction as `get_staff_context`: a relationship-bound staff credential may only review itself. Reads precomputed `graph.edge` + `graph.connection_score` — no write.

### `get_location_context`

**Parameters:** `location_id` (required), `purpose` (required). Returns the location's core facts, operational resilience (cover depth, single points of failure), and the **aggregate** distribution of members across the four connection-strength quadrants. No member-level PII — counts only.

### `list_quadrant_members`

The act-on-it complement to `get_location_context`: where the latter gives counts, this lists the individual members. **Parameters:** `location_id` (required), `purpose` (required), `quadrant` (optional — one of the four quadrant names; omit for all classified members), `max_resilience` (optional, 0–1 — only members at or below this connection-strength ceiling, to target the weakest-connection tail), `limit` (optional, default 100, max 500). Each member comes with their `resilience`, activity affinity, contract commitment, quadrant, primary-staff routing and a `risk_category`, ordered weakest-connection first. Names follow your purpose (anonymised under analysis, real under `action_board`).

### `simulate_departure`

**Parameters:** `staff_id` (required), `location_id` (required), `purpose` (required), `limit` (optional, default 100, max 500). Models the impact if a staff member left: returns the members connected to them — those who would lose their strongest connection — each with edge weight, connection strength (constrained `resilience`) and a `risk_category`, ordered weakest-connection first. Reads precomputed weights only — no write.

### `get_entity_edges`

**Parameters:** `entity_id` (required), `entity_type` (required — `member` | `staff` | `location`), `purpose` (required), `min_weight` (optional, default 0), `limit` (optional, default 100, max 500). Returns the active edges connected to the entity, with edge class (MS/ML/SL/MM/SS), kind, weight, tier and recency.

## The summary tools

Lightweight rollup reads that don't take a purpose — handy for quick routing questions.

| Tool                      | Min level  | What it does                                                                                                                      |
| ------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `get_member_affinity`     | operations | A member's primary coach, top-3 staff, and how concentrated their affinity is on that one coach.                                  |
| `get_staff_concentration` | operations | Per-staff routing value: how many members' connections are anchored on this coach (members whose primary affinity is this coach). |
| `get_open_signals`        | operations | The open attention-signal queue. Optional `signal_type` filter; highest severity first.                                           |
| `get_cac_by_cohort`       | analytics  | Customer-acquisition cost by cohort (a marketing rollup, listed here for convenience).                                            |

## Querying the raw tables

For anything the tools don't shape for you, query the `graph.*` schema directly with `execute_query` (SELECT/`WITH` only):

```sql
-- the open attention-signal queue, most severe first
SELECT person_id, signal_type, severity, context, detected_at
FROM graph.risk_signal
WHERE resolved_at IS NULL
ORDER BY severity DESC, detected_at DESC;

-- the members with the weakest connections at a location
SELECT person_id, vuln_quadrant, resilience_constrained
FROM graph.member_resilience
WHERE location_id = '…'
ORDER BY resilience_constrained ASC;
```

See the [schema reference](/for-developers/schema) for the `graph.*` tables.

## Freshness

The graph is **derived**, so it lags its inputs until it rebuilds. It refreshes automatically after an import or a connector restate that changes its inputs (attendance, members, plans), and a nightly sweep is the backstop. Scores and signals therefore reflect the most recent build, not the live second.

## Where to go next

* [Tool reference](/for-developers/tools) — the complete tool catalogue.
* [Schema reference](/for-developers/schema) — the `graph.*` and other tables.
* [Permission levels](/connect-claude-and-access/scopes) — what each level can see.


# OAuth

How Kula Intelligence implements OAuth 2.1 for MCP clients — discovery, dynamic client registration, PKCE, scopes, and audience binding.

Kula Intelligence ships a standards-based **OAuth 2.1** authorization server so MCP clients can sign a user in and receive an access token, with no token-pasting. It implements the discovery and security RFCs the MCP ecosystem relies on.

For end users, the flow is invisible — they click "connect" and sign in. This page is the detail for client developers.

> **Not every client does OAuth.** Clients without a custom-connector flow (ChatGPT, Cursor, Lovable, the Claude API, custom agents) connect with a **role-scoped access token** minted in console.kula.digital instead — the same minted MCP credential, presented as a bearer header against `https://mcp.kula.digital/mcp`. See [Connect](/for-developers/connect). This page covers the OAuth path used by Claude's custom connector.

## The two hosts

| Role                                 | Host               | Serves                                                                                                  |
| ------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- |
| **Resource server** (the MCP server) | `mcp.kula.digital` | the per-connector resource `/connect/{…}/mcp`, and its protected-resource metadata                      |
| **Authorization server**             | `app.kula.digital` | discovery, registration, authorize, token (connectors themselves are created in `console.kula.digital`) |

Clients are pointed at a **role-scoped connect link** (`https://mcp.kula.digital/connect/{…}/mcp`) created in console.kula.digital — there is no open `/mcp` endpoint to connect to. The resource advertises the authorization server, so a compliant client only needs the connect link to discover everything else. For a connect link, the protected-resource metadata is path-scoped: `/.well-known/oauth-protected-resource/connect/{…}/mcp`.

## Discovery

1. **Protected-resource metadata** (RFC 9728):

   ```
   GET https://mcp.kula.digital/.well-known/oauth-protected-resource
   ```

   Returns the canonical resource URL and the `authorization_servers` list (pointing at `app.kula.digital`).
2. **Authorization-server metadata** (RFC 8414):

   ```
   GET https://app.kula.digital/.well-known/oauth-authorization-server
   ```

   Returns the `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `code_challenge_methods_supported` (`["S256"]`), `grant_types_supported` (`authorization_code`, `refresh_token`), and `scopes_supported`.

## Dynamic client registration (RFC 7591)

Clients may self-register:

```
POST https://app.kula.digital/oauth/register
{ "redirect_uris": ["https://your-app.example/callback"], "client_name": "…" }
```

Returns a `client_id` (and a secret only for confidential clients; public clients use `token_endpoint_auth_method: "none"` with PKCE).

For Claude's hosted clients, register the callback `https://claude.ai/api/mcp/auth_callback`. For Claude Code, **loopback redirects** on `http://localhost:<port>` are accepted with port-agnostic matching.

## Authorization + token

* **Authorize** (`GET /oauth/authorize`) requires `response_type=code`, `code_challenge`, and `code_challenge_method=S256` (**PKCE is mandatory**). The user is signed in (delegated to our identity provider), consents, and is redirected back with a `code`.
* **Token** (`POST /oauth/token`) accepts `application/x-www-form-urlencoded` for the `authorization_code` grant (with `code_verifier`) and the `refresh_token` grant. Refresh tokens are **rotated** on use. Responses carry `Cache-Control: no-store`.

The access token is the same minted MCP token the runtime verifies — OAuth adds consent and discovery without introducing a new token type.

## Scopes

The OAuth `scope` maps to Kula's [permission levels](/connect-claude-and-access/scopes):

| Scope            | Means                                                        |
| ---------------- | ------------------------------------------------------------ |
| `analytics`      | Aggregates only; no personal data                            |
| `operations`     | Member/staff names visible; contact + payment details hidden |
| `admin`          | Emails/phones visible; payment details hidden                |
| `full`           | Everything; every call recorded                              |
| `offline_access` | Issue a refresh token                                        |

Request the **lowest** scope the integration needs.

## Audience binding

Tokens are bound to the canonical MCP resource URL (RFC 8707 resource indicator). The runtime verifies the token's audience, so a token minted for Kula can't be replayed against a different server.

## Revocation

Connectors are revocable from console.kula.digital and stop working within about a minute. Clients should handle a `401` by re-running the OAuth flow; if the connect link itself has been revoked or used up, the user creates a fresh connector.


# Changelog & versioning

How the Kula Intelligence tool surface evolves, our compatibility promises, and notable changes.

This page explains how the connector evolves and records notable changes.

## Versioning policy

* **The MCP protocol version is negotiated** per connection, so clients and the server agree on a compatible version at handshake time.
* **The tool surface is additive by default.** New tools and new optional parameters can appear at any time without warning — clients should ignore tools they don't recognise.
* **Breaking changes are announced.** Removing a tool, renaming it, removing a parameter, or changing a result shape in a non-additive way is a breaking change. We announce these ahead of time here and, where it matters, keep the old behaviour available during a transition window.
* **The event contract is versioned.** Data pushed into the platform carries a `schema_version` (currently `1.0`); the ingest endpoint defaults it when omitted.

## Compatibility expectations for clients

* Read tool annotations (`readOnlyHint`, `destructiveHint`) rather than assuming behaviour from a tool's name.
* Treat unknown fields in a result as forward-compatible — don't fail on them.
* Handle `401` by refreshing auth, and `403` as a permission-level signal (the token's [scope](/connect-claude-and-access/scopes) is too low for that tool).

## Discovering the live surface

The authoritative tool list is whatever the server advertises at connect time. The [tool reference](/for-developers/tools) mirrors it, and the [schema reference](/for-developers/schema) documents the tables behind the freeform query tools. When in doubt, ask a connected client to list its tools.

## Notable changes

> Dates are listed newest first.

* **Data coverage & windowed re-pull.** Every connector now shows a **Data coverage** card — a day-by-day grid of each data stream, charted by true event date, with any gaps flagged. Operators can **Re-pull** a single missing window (an idempotent, gap-filling force-refresh that re-runs Process over just that range) or **mark a quiet day expected** (one-off or annually recurring) so it stops counting as missing. A nightly sweep tops every connector up and re-processes, closing most gaps automatically. See [Keeping your connectors healthy](/your-data-sources/operating).
* **GymMaster connector.** Members, staff, clubs, classes, attendance, and memberships from GymMaster, read-only, on the same Test → Connect → Import → Process rhythm as the other booking sources. See [GymMaster](/your-data-sources/gymmaster).
* **Initial public documentation.** Owner, connector, skills, consultant, and developer guides published; OAuth connector front door and the full tool surface documented.

*Material changes will be appended here as they ship.*


# Security & data handling

How Kula Intelligence protects your studio's data — isolation, encryption, access control, auditing, and what we deliberately don't do.

This is the technical companion to the plain-English [Is my data safe?](/get-started/trust). It's written for security-minded operators, their advisers, and reviewers.

## Tenancy: isolation by database boundary

Per-studio isolation is the load-bearing design decision.

* **Every studio gets its own database.** Each studio's data lives in its own dedicated Postgres database, provisioned per tenant.
* **There is no cross-studio read path.** A request is bound to exactly one studio, derived from the verified token — never from a parameter a caller can set. The connection it runs on points at that studio's database and no other.
* **No shared-table tenancy.** We don't use a shared multi-tenant table keyed by an org id, so there's no class of query that could leak across tenants. Isolation is physical, not a filter that could be forgotten.

## Authentication & access control

* **Two separate identity paths, never mixed.** Operators sign in through a dedicated identity provider; AI clients present a minted access token verified by a separate path. They share no trust.
* **Role-scoped credentials only — no anonymous access.** Every client connects with a credential created in console.kula.digital: a **connect link** (`/connect/{…}/mcp`, OAuth 2.1 with DCR + PKCE) for Claude, or a **role-scoped access token** (bearer) for other clients. The endpoint rejects any request without a valid token. See [OAuth](/for-developers/oauth).
* **Permission levels.** Every connect link carries a permission level that controls how much personal data it can ever see; redaction is applied server-side, so a lower level cannot be coaxed into revealing more. See [Permission levels](/connect-claude-and-access/scopes).
* **Audience binding.** Credentials are bound to the Kula MCP resource, so they can't be replayed against a different server.

## Revocation

* Connectors are revocable from console.kula.digital.
* Revocation takes effect within about **a minute** — the runtime never trusts a cached authorisation longer than that.
* Closing a connection simply shuts the door a client came through; the studio's data remains in its own database, under the owner's account.

## Encryption

* **In transit:** all client and ingest traffic is over HTTPS/TLS.
* **At rest:** studio databases and stored documents are encrypted at rest by the underlying platforms.
* **Provider secrets** (the keys or tokens you paste when connecting a data source) are stored **AES-256-GCM-encrypted at rest in your studio's own database** — never in plaintext, never shared between studios. The encryption key is held separately in our secrets infrastructure, so the database contents alone can't reveal them. They're used only to read from that provider.

## Auditing

* **Every privileged action is recorded** — token mint and revoke, guarded writes, and sensitive reads — through a single audit path, not scattered ad-hoc logging.
* **`full`-level reads are recorded on every call**, so the most sensitive access always leaves a trail an operator can inspect.

## How data flows in

* Connectors **read** from the tools you authorise and push canonical events into your studio's database over an authenticated, signed channel.
* Data lands verbatim first, then a clean copy is transformed, so re-processing is always safe. The verbatim copy is kept for **30 days** then purged; the transformed data is what the AI reads. See [How connectors work](/your-data-sources/connectors).
* The runtime itself is **model-free**: it runs no AI inference over your data. All reasoning happens in the AI client you connect, under your control. (The one exception is optional semantic search, below.)

## Optional semantic search

* Off by default. A studio can opt in to vector search, which converts text to embeddings for similarity matching.
* Embeddings are generated only with the studio's **explicit consent**, and the data stays within our cloud region.

## Data location

* Studio data is stored in the cloud region you're provisioned in. Australian studios' data stays in Australia.

## Sub-processors

We rely on a small set of infrastructure providers — for managed Postgres, cloud hosting and storage, operator sign-in, and the optional embeddings service. The current list and their roles are in the [Privacy policy](/trust-and-legal/privacy). We never sell your data.

## What we deliberately do **not** do

* **No money movement.** No charges, refunds, transfers, or payouts.
* **No AI media generation.**
* **No cross-studio reads** — not even for aggregate analytics.
* **No arbitrary database mutation.** Freeform queries are read-only (`SELECT`/`WITH`); writes happen only through specific, scope-gated, audited tools.
* **No writing back to your connected tools.** Connectors read; they never modify the source system.

## Reporting a vulnerability

Please follow [Responsible disclosure](/trust-and-legal/disclosure). We welcome good-faith reports and will work with you on a fix.


# Privacy policy

How Kula Intelligence collects, uses, stores, shares, and retains data — what it deliberately never does — and your choices and rights.

> *Version 1.0 · Last updated 19 June 2026.* This policy is **specific to Kula Intelligence**, the MCP connector described below. It is separate from the privacy policies of other Kula products. The substance reflects how the product works today.

Kula Intelligence is operated by **Kula Holdings Pty Ltd** (ABN 53 676 723 452) ("Kula", "we", "us"), Sydney, Australia. This policy explains what data Kula Intelligence handles, why, how long we keep it, who we share it with, what we deliberately never do, and the choices and rights you have.

## 1. What this policy covers

Kula Intelligence is a **Model Context Protocol (MCP) control-plane** for boutique fitness studios. It gives an AI client **you choose** (such as Claude or ChatGPT) a private, scoped, read-mostly window onto **your own studio's data**, so you can ask questions in plain English and get answers built from your numbers.

This policy applies to that connector and the operator apps used to set it up. It does not govern the separate Kula products you may also use, nor the AI client you connect — see [Connected AI clients](#6-connected-ai-clients).

## 2. Our two roles

* **For studio operators (our customers):** we are the **controller** of your account information.
* **For a studio's own data (members, sales, bookings, accounting, marketing):** the studio is the **controller** and Kula acts as a **processor** on the studio's instructions. We access that data only to provide the service to the studio that connected it.

Some studio data may include **health or other sensitive information** (for example injury notes or health-related attendance flags), which receives stronger protection under the Privacy Act 1988 (Cth). As the controller of its member data, the studio is responsible for obtaining any consent required to collect that information and disclose it to us, and we process it only on the studio's instructions as its processor (see your [responsibilities under the Terms](/trust-and-legal/terms)).

## 3. What we collect

**1. Account information.** When you create an account: your name, email, studio name, region, timezone, and sign-in identifiers from our identity provider. Billing details if you subscribe (processed by our payment provider, Stripe — see [sub-processors](#7-who-we-share-data-with-sub-processors)).

**2. Studio operational data (via connectors).** When you connect a data source, we read and store a working copy of the relevant records so the AI can answer questions. Depending on which sources you connect, this can include:

* **Members & contacts** — names, email addresses, phone numbers, membership status and plans (e.g. from your booking platform or Wix).
* **Bookings & attendance** — classes, visits, check-ins, cancellations.
* **Sales & payments** — sale amounts, plans, payment method and status, and limited payment metadata. We store at most the **last four digits** of a card; we never receive or store full card numbers.
* **Accounting** — contacts, invoices, transactions, and (where your plan allows) general-ledger data from Xero.
* **Marketing & web analytics** — advertising performance and spend from Meta, and **aggregated** website metrics from Google Analytics 4 (not individual visitor identities).

Exactly what each connector reads — and what it does not — is listed on each [connector page](/your-data-sources/connectors).

**3. Provider credentials.** The keys or tokens you provide to connect a source are stored **encrypted at rest (AES-256-GCM) in your studio's own database**, with the encryption key held separately. They are used only to read from that provider and are never shared between studios.

**4. Connector usage & audit data.** To operate the service, support you, and keep an audit trail, we record **metadata about how the connector is used** — which tools were called, when, by which credential, how long they took, whether they succeeded, and privileged actions. This is operational telemetry about tool calls, not the content of your conversations (see the next section).

## 4. What we do **not** collect or do

This is as important as what we collect. Kula Intelligence:

* **Does not collect your AI conversations.** We never receive or store the text of your prompts, the AI's responses, conversation transcripts, conversation summaries, or token counts. Our "usage" data is limited to tool-call metadata as described above.
* **Does not access your AI client's memory, chat history, or uploaded files.** The connector reads only your studio's connected data, from your studio's own database. It has no access to anything else in your AI client.
* **Does not move money or generate media.** It never makes payments, issues refunds, transfers funds, runs payroll, or generates images, audio, or video.
* **Does not write back to your connected tools.** It is read-mostly; the limited writes it makes are confined to your own Kula database (e.g. saved views, notes), never to the source systems.
* **Does not receive full payment card numbers**, and **does not track individual website visitors** (GA4 data is aggregated).
* **Does not sell your data, ever**, and does not share it with third parties for their own marketing.
* **Does not use one studio's data to answer another studio's questions** — there is no cross-studio read path (see [Security](#10-security)).
* **Does not use your studio's data to train cross-customer or general-purpose AI models.**

## 5. How we use data

* **To provide the service** — to let the AI client you connect answer questions about your studio.
* **To operate and support** — diagnostics, troubleshooting, security, audit, and billing.
* **Optional semantic search** — only if you opt in, we generate embeddings from your text to power similarity search; that data stays in your provisioned cloud region.

We do **not** use your studio's data to train cross-customer or general-purpose AI models, and we do not profile your members for any purpose other than answering your own questions about your own studio.

### Direct marketing

We may send you service and product communications about Kula Intelligence (for example onboarding, security, and feature updates). Where we send promotional messages, we include a way to opt out and honour your request promptly, consistent with the Privacy Act 1988 (Cth) and the Spam Act 2003 (Cth). We do not use your studio members' personal information for our own marketing, and we never sell data or disclose it for third parties' marketing.

## 6. Connected AI clients

Kula Intelligence is the bridge between your data and **an AI client you choose**. When you ask a question, the relevant results are sent to that AI provider so it can answer you. That exchange is governed by **your agreement with that AI provider**, not by Kula, and the AI provider's own privacy terms apply to what it does with the prompt and the data it receives.

* You choose a [**permission level**](/connect-claude-and-access/scopes) that controls how much personal data the assistant can see — for example, limiting names and contact details so the AI works with aggregates instead.
* Where we engage AI providers as our own sub-processors (for the optional embeddings feature), we do so under data-processing terms that **restrict use of your data for AI-model training**.

## 7. Who we share data with (sub-processors)

We use a small set of providers to run the service. Each processes data only to provide its part of the service:

| Provider              | Role                                                                                                                            | Privacy policy                                        |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Neon                  | Managed Postgres — your studio's database                                                                                       | <https://neon.tech/privacy-policy>                    |
| Google Cloud Platform | Cloud hosting, document storage, secrets, and the optional embeddings service (Vertex AI), processed in your provisioned region | <https://cloud.google.com/terms/cloud-privacy-notice> |
| Kinde                 | Operator sign-in / identity                                                                                                     | <https://kinde.com/privacy-policy/>                   |
| Vercel                | Hosting for the operator web apps                                                                                               | <https://vercel.com/legal/privacy-policy>             |
| Resend                | Transactional email — connect-link invites, secure token-reveal emails, and connector notifications to operators                | <https://resend.com/legal/privacy-policy>             |
| Twilio                | SMS delivery — secure one-time token-reveal links (where SMS handoff is enabled)                                                | <https://www.twilio.com/en-us/legal/privacy>          |
| Stripe                | Payment processing for Kula subscription billing (where you subscribe to a paid plan)                                           | <https://stripe.com/privacy>                          |

In addition, the **AI client you connect** (e.g. Claude / Anthropic, ChatGPT / OpenAI) receives your query results at the moment you ask a question, under your agreement with that provider, as described in [Connected AI clients](#6-connected-ai-clients).

We may update this list as our infrastructure evolves; the current list will always be here. We do not share your data with third parties for their own marketing.

## 8. Where data is stored and cross-border transfers

Your studio's data is stored in the **cloud region you are provisioned in**. Australian studios' data is stored in Australia.

Some sub-processors operate outside Australia — in particular Google Cloud Platform (United States and other regions), Resend and Twilio (United States), and, where you connect them, AI providers such as Anthropic (United States) or OpenAI (United States). Where we disclose personal information to an overseas recipient, we take reasonable steps to ensure it is handled consistently with the Australian Privacy Principles, including through contractual data-processing terms. The AI client you connect receives your query results under your **own agreement with that provider**; because you choose and direct that disclosure, the provider's handling of that data is governed by your agreement with it rather than by us.

## 9. How long we keep data

* **While your account is active**, we retain your studio's transformed (canonical) data so the service keeps working — this is what makes 12-month trends and history possible.
* **The raw imported copy** — the verbatim vendor data we land before transforming it — is kept only long enough to re-process safely: **30 days, then it is purged.** The canonical data it produced is unaffected.
* **When you disconnect a data source**, the raw copy we hold from that source is purged within **30 days**. The canonical data already derived from it is retained while your account stays active, so your history and trends are preserved.
* **When you close your account**, you can ask us to transfer your studio's database to you; otherwise it is purged. Either way the copies we hold are removed within **30 days** of closure, except where we must retain limited records to meet a legal, tax, or accounting obligation.

## 10. Security

We protect data with:

* **Per-studio isolation** — each studio's data lives in its **own database**. There is no cross-tenant read path; one studio's data is never used to answer another studio's questions.
* **Encryption** — in transit (TLS) and at rest, including AES-256-GCM encryption of the provider credentials you supply, with the key held separately.
* **Scoped access and auditing** — every privileged action is recorded, and the permission level you choose limits what personal data is exposed.
* **Revocable access** — you can revoke a connection or token in one click from the operator app, and revocation takes effect on the connector promptly (near real-time).

For more detail see [Security & data handling](/trust-and-legal/security). To report a vulnerability, see [Responsible disclosure](/trust-and-legal/disclosure) (**<security@kula.digital>**).

### Data breaches

If we become aware of a data breach affecting personal data we hold that is likely to result in serious harm, we will assess it and, where the Notifiable Data Breaches scheme under the Privacy Act 1988 (Cth) applies, notify affected individuals and the Office of the Australian Information Commissioner as soon as practicable, consistent with our legal obligations. Where a studio is the controller of affected member data, we will notify and support that studio (as its processor) so it can meet its own notification obligations, and we will assist with containment and remediation.

## 11. Your rights

Depending on where you are, you may have rights to access, correct, export, or delete personal data, and to object to or restrict certain processing.

* **Operators:** you may ask us to access or correct the personal information we hold about you by emailing **<privacy@kula.digital>**. We will respond within a reasonable period (generally within 30 days). Access to your own account information is free; if a request is complex we may charge a reasonable, cost-based fee and will tell you the basis beforehand. If we cannot provide access or make a correction, we will tell you why in writing and how to complain, and where you dispute the accuracy of information and we do not correct it, you may ask us to associate a statement noting your view.
* **A studio's members:** because the studio controls its member data, direct requests to the studio; we will assist the studio in fulfilling them as its processor.

## 12. Complaints

If you have a privacy concern, contact us first at **<privacy@kula.digital>** and we will acknowledge your complaint within **5 business days** and work to resolve it. If you are in Australia and are not satisfied with our response, you may escalate to the Office of the Australian Information Commissioner (OAIC) at [oaic.gov.au](https://www.oaic.gov.au).

## 13. Children

Kula Intelligence is a business tool, not directed at children and not intended for the collection of children's data.

## 14. Changes to this policy

We will update this page when our practices change and revise the "last updated" line. We will communicate material changes to operators.

## 15. Contact

Questions or requests: **<privacy@kula.digital>** (attn: Privacy Officer) — Kula Holdings Pty Ltd (ABN 53 676 723 452), Sydney NSW, Australia *(registered office address to be confirmed)*. For security matters: **<security@kula.digital>**. For other support: **<support@kula.digital>**.


# Terms of service

The terms governing use of Kula Intelligence — what the service is, your responsibilities, AI-output limits, what it never does, and the legal terms.

> *Version 1.0 · Last updated 19 June 2026.* These terms are **specific to Kula Intelligence**, the MCP connector described below, and are separate from the terms of other Kula products.

These terms govern your use of **Kula Intelligence**, operated by **Kula Holdings Pty Ltd** (ABN 53 676 723 452) ("Kula", "we", "us"), Sydney, Australia. By creating an account or connecting an AI client, you agree to them.

## 1. The service

Kula Intelligence is an **MCP control-plane** that gives an AI client you choose a **scoped, audited, read-mostly** window onto your studio's own data. It reads from the tools you connect and surfaces answers in your AI client. It is **not** a booking platform, a payment processor, a payroll system, or a system of record, and it is separate from any other Kula product you may use.

## 2. Eligibility and your account

* You must provide accurate information and keep your credentials secure.
* You are responsible for activity under your account and the tokens or connections you create.
* You must be authorised to act for the studio you onboard.

## 3. Connecting data — your responsibilities

When you connect a data source, you confirm that:

* You have the right and authority to grant Kula read access to that data.
* You have any necessary consents and a lawful basis for us to process the personal data involved (including your members' data) as your processor.
* You will use the appropriate [permission level](/connect-claude-and-access/scopes) for each integration.

You remain the controller of your studio's data; see the [Privacy policy](/trust-and-legal/privacy).

## 4. Acceptable use

You agree not to:

* Use the service to access data you are not authorised to access, or to attempt to reach another studio's data.
* Probe, scan, or test the security of the service except under [Responsible disclosure](/trust-and-legal/disclosure).
* Use the service unlawfully, or to harass or harm individuals.
* Resell or provide the service to third parties except as expressly permitted (consultants acting for a studio with its authorisation are permitted — see [the consultant guide](/for-consultants/consultants)).

## 5. AI output — read this

Answers are generated by an AI client reading your data and **may be incomplete or wrong**. **Kula does not guarantee the accuracy of any answer.** Treat output as decision support, not professional, financial, legal, medical, or tax advice, and verify anything material before acting on it. You are responsible for the decisions you make using the service.

## 6. What the service does not do

The service **does not move money, issue refunds, make payments, run payroll, generate media, or write back to your connected tools.** Do not rely on it to perform any such action. The limited writes it makes are confined to your own Kula database (e.g. saved views and notes), never to your source systems.

## 7. Your data and our intellectual property

* **Your data stays yours.** We claim no ownership of your studio's data and use it only to provide the service, as described in the [Privacy policy](/trust-and-legal/privacy).
* **Our software stays ours.** We retain all rights in the Kula Intelligence software, tools, and documentation. You get a limited, non-exclusive, non-transferable right to use the service while these terms are in effect.

## 8. Third-party services and AI clients

Kula Intelligence connects to **data sources you choose** (such as Stripe, Wix, Mindbody, Xero, Meta, or Google Analytics) and to **an AI client you choose** (such as Claude or ChatGPT). Your use of those third-party services is governed by **your agreements with them**, not by these terms, and when you ask a question the relevant results are sent to your chosen AI provider under your agreement with that provider. We rely on the sub-processors listed in the [Privacy policy](/trust-and-legal/privacy#7-who-we-share-data-with-sub-processors) to operate the service.

## 9. Availability and changes

We aim for a reliable service but, particularly during early access, do not commit to a specific uptime level unless agreed separately in writing. We may change, add, or remove features; material changes to the tool surface are noted in the [changelog](/for-developers/changelog).

## 10. Fees

Where the service is offered on a paid basis, fees and billing terms are as presented when you subscribe. Early-access terms may differ and will be made clear to you.

## 11. Suspension and termination

* You may stop using the service and close your account at any time.
* We may suspend or terminate access for breach of these terms, for non-payment, or where required to protect the service or other users.
* On termination we handle your data as described in the [Privacy policy](/trust-and-legal/privacy#9-how-long-we-keep-data).

## 12. Warranties and disclaimers

To the extent permitted by law, the service is provided **"as is" and "as available"**, without warranties of any kind, whether express or implied, including fitness for a particular purpose, accuracy of AI output, or uninterrupted availability. Nothing in these terms excludes, restricts, or modifies any consumer guarantee or other right that cannot be excluded under the Australian Consumer Law or other applicable law.

## 13. Limitation of liability

To the maximum extent permitted by law, and subject to any non-excludable rights under the Australian Consumer Law:

* Neither party is liable for indirect, incidental, special, or consequential loss, or for loss of profits, revenue, data, or goodwill.
* Our total aggregate liability arising out of or in connection with the service is limited to the fees you paid us for the service in the **3 months** before the event giving rise to the claim (or, where the service was provided free of charge, a nominal amount). *(Liability cap and carve-outs to be confirmed by counsel.)*

## 14. Indemnity

You agree to indemnify us against claims, losses, and costs arising from your breach of these terms, your lack of authority or lawful basis to connect a data source, or your use of AI output in breach of section 5, except to the extent the claim arises from our own breach or negligence.

## 15. Distribution through AI connector directories

Where Kula Intelligence is listed in a third-party AI connector directory, your use through that directory is also subject to that provider's applicable directory and usage terms, in addition to these terms.

## 16. Governing law and jurisdiction

These terms are governed by the laws of **New South Wales, Australia**, and you and we submit to the non-exclusive jurisdiction of the courts of that State.

## 17. Complaints and disputes

If you have a concern, contact us first at **<legal@kula.digital>** so we can try to resolve it. For privacy complaints, see the [Privacy policy](/trust-and-legal/privacy#12-complaints).

## 18. Changes to these terms

We may update these terms from time to time and will revise the "last updated" line. We will communicate material changes to operators; your continued use after a change takes effect constitutes acceptance.

## 19. General

* **Severability** — if any provision is unenforceable, it is severed and the rest remains in force.
* **Entire agreement** — these terms, together with the documents they reference, are the entire agreement between us about the service and supersede prior discussions.
* **Waiver** — a failure to enforce a term is not a waiver of it.
* **Assignment** — you may not assign these terms without our consent; we may assign them to an affiliate or in connection with a merger, sale, or reorganisation.
* **Force majeure** — neither party is liable for delay or failure caused by events beyond its reasonable control.
* **Notices** — we may give notice by email to your account address or by posting in the operator app; notices to us go to **<legal@kula.digital>**.
* **No partnership** — nothing in these terms creates a partnership, agency, or employment relationship.
* **Survival** — sections 5, 7, 12, 13, 14, 16, and any clause that by its nature should survive, survive termination.

## 20. Contact

**<legal@kula.digital>** — Kula Holdings Pty Ltd (ABN 53 676 723 452), Sydney NSW, Australia *(registered office address to be confirmed)*.


# Kula Skills Licence

The licence covering Kula's skill library — what you may do with skill content, what stays Kula's, how provenance fingerprints work, and how paid skills are licensed.

> *Version 1.0 · Last updated 3 July 2026.* This licence covers the **skill content** Kula publishes — it sits alongside the [Terms of service](/trust-and-legal/terms), which govern the service itself.

**Kula skills** are the playbooks, reference files, scripts, and templates that Kula Holdings Pty Ltd (ABN 53 676 723 452) ("Kula", "we", "us") curates and serves to your AI client — everything surfaced through the skills catalogue: skill bodies (SKILL.md instructions), `references/`, `scripts/`, `assets/`, and `agents/` files.

## 1. Ownership

All Kula-curated skill content (the "kula" and "canonical" tiers of the catalogue) is the copyright of Kula Holdings Pty Ltd. All rights reserved. It is **licensed, not sold**.

Skills **your studio authors itself** — including your own edits to a skill you have forked — remain **your studio's content**. Kula's notice is never applied to them, and this licence does not claim them.

## 2. What you may do

While your studio holds an active Kula Intelligence subscription (and, for paid skills, the relevant entitlement):

* **Use** Kula skills inside your studio, through any AI client connected to your organisation.
* **Fork and customise** a Kula skill for your studio's internal use.
* **Share output** the skills help produce (reports, analyses, messages) — output about your data is yours.

## 3. What you may not do

* **Redistribute** skill content outside your organisation — including posting it publicly, sharing bundles with other businesses, or re-serving it through another product.
* **Resell** or sublicense skill content, or build a competing catalogue from it.
* **Remove or alter** copyright notices, licence lines, or provenance fingerprints embedded in served content.

Consultants acting for a studio with its authorisation may use that studio's skills on its behalf — see [the consultant guide](/for-consultants/consultants).

## 4. Provenance fingerprints — how we track copies

Every piece of shared-tier skill content served to your organisation carries a short **licence trailer**: the copyright line, a link to this page, your organisation code, and a **provenance code** (e.g. `Provenance: KW1-…`). The code is computed from your organisation, the skill, the file, and its content — it identifies **which organisation a copy was served to**, so content found outside a licensed studio can be traced to its source. It encodes no member data and nothing about how you use the skill. Serving is also metered per organisation, as described in the [Privacy policy](/trust-and-legal/privacy).

## 5. Paid skills and the marketplace

Some skills are **included** with your subscription; others are **paid add-ons**. Paid skills stay visible in the catalogue but their content is locked until your studio adds them from **Settings → Skills** in the Kula console. Commercial licensing beyond studio-internal use — redistribution, bundling into another product, multi-brand use — is available by arrangement: contact us via the details in the [Terms of service](/trust-and-legal/terms) and we will always offer a paid path rather than a refusal.

## 6. Termination

If your subscription or a paid-skill entitlement ends, the licence to the corresponding Kula skill content ends with it. Copies made under clause 2 should be deleted; your own authored content and your data are unaffected.

## 7. Changes

We may update skill content and this licence; material changes are announced in the console and dated here. Continued use after a change is acceptance.


# Responsible disclosure

How to report a security vulnerability in Kula Intelligence.

We take the security of studios' data seriously and welcome good-faith reports from security researchers and users.

## How to report

Email **<security@kula.digital>** with:

* A description of the issue and where you found it.
* Steps to reproduce (proof-of-concept, requests, screenshots).
* The potential impact as you see it.

If you need to share sensitive details, ask in your first message and we'll arrange an encrypted channel.

## What to expect

* We aim to **acknowledge** your report within a few business days.
* We'll keep you updated on our assessment and the fix.
* With your permission, we're happy to credit you once the issue is resolved.

## Good-faith guidelines

Please help us keep studios' data safe while you research:

* **Only test against your own account or data**, or a test account we provide. Never access, modify, or exfiltrate another studio's data.
* **Don't run** denial-of-service tests, spam, or social-engineering against our staff or users.
* **Give us reasonable time** to fix an issue before disclosing it publicly.

We will not pursue or support legal action against researchers who act in good faith and follow these guidelines.

## Out of scope

Reports that are typically not actionable on their own: missing security headers without a demonstrated impact, rate-limiting on non-sensitive endpoints, and findings that require a compromised device or a already-privileged account. When in doubt, send it anyway — we'd rather hear about it.

## A note for connected AI clients

Kula Intelligence is read-mostly and tightly scoped by design — it moves no money and writes nothing back to your connected tools (see [Security & data handling](/trust-and-legal/security)). If you believe a tool behaves outside those bounds, that's exactly the kind of report we want.


# Reviewer guide

For Anthropic connector reviewers — test-account access, every tool with an example prompt, the OAuth round-trip, and the isolation model.

> **For connector-directory reviewers.** Pair this with the internal submission packet at `docs/CONNECTOR_SUBMISSION_PACKET.md` in the repo.

Thanks for reviewing Kula Intelligence. This page gets you from zero to exercising every tool against a **fully-populated demo studio**.

## What this connector is

A remote MCP server that gives an AI client a scoped, audited, **read-mostly** window onto one boutique fitness studio's operational data (members, attendance, sales, accounting, marketing). One connect link = one studio at one permission level; tenancy is a hard database boundary. It moves no money, generates no media, and never reads across studios. There is **no open endpoint** — all access is through a role-scoped connect link.

* **Connect link form:** `https://mcp.kula.digital/connect/{…}/mcp` (streamable HTTP)
* **OAuth metadata (per link):** `https://mcp.kula.digital/.well-known/oauth-protected-resource/connect/{…}/mcp`
* **Public docs:** `https://docs.kula.digital`

## Test account

> *The Kula team fills in the live values below before submission.*

We provide a seeded demo studio ("Anthropic Connector Review") with realistic members, classes, attendance, sales, and accounting so every tool returns real results.

* **Reviewer connect link:** *provided with the submission* — a role-scoped link of the form `https://mcp.kula.digital/connect/{…}/mcp`.
* **Permission level:** the demo connect link is scoped to **operations** (member names visible; emails, phones, and payment details redacted) — enough to exercise the full surface. A **full**-level link can be provided on request to review redaction behaviour.

## Connecting

**In Claude (recommended):** add the provided **connect link** (`https://mcp.kula.digital/connect/{…}/mcp`) as a custom connector (Settings → Connectors → Add custom connector; leave Advanced settings blank). On connect, the client discovers the authorization server, you sign in to the demo account, and consent — then the connector is live. Discovery, dynamic client registration, and PKCE are all implemented — see [OAuth](/for-developers/oauth). The callback `https://claude.ai/api/mcp/auth_callback` is registered, and Claude Code loopback redirects are accepted.

Opening the connect link **without** the trailing `/mcp` renders the human-facing connect page, which offers an **Open in Claude** button that pre-fills Claude's Add-custom-connector dialog with the connector name and the same URL. It is a convenience only: it fills in the two fields above and changes nothing about discovery, registration, consent, or scope. The page also shows the URL in full for manual entry.

**Other clients:** we can instead provide a **role-scoped access token** for the demo studio, used as `Authorization: Bearer <token>` against `https://mcp.kula.digital/mcp`. Either way, access is via a role-scoped credential created in console.kula.digital — there is no anonymous access.

Verify with: *"List the tools you can use from Kula Intelligence."*

## Exercising the tools

These prompts return real data against the demo studio at its **operations** permission level.

| Try this prompt                                                                                                    | Exercises                                                            |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| "List the tables you can see and their columns for `commerce.sale`."                                               | `list_tables`, `get_table_schema`                                    |
| "How many recurring members do we have and how has attendance trended over 12 months?"                             | `execute_query`, `get_semantic_catalogue`                            |
| "Which recurring members have open attention signals right now, and what's their plan status?"                     | `get_open_signals`, `get_member_plan_status`                         |
| "Who are my top 5 current members?"                                                                                | `execute_query`                                                      |
| "Show the relationship context for one of those members."                                                          | `get_member_context`, `get_entity_edges`                             |
| "Who are this member's main coaches, and how concentrated is the studio on its top staff?"                         | `get_member_affinity`, `get_staff_concentration`                     |
| "How is our recurring members' connection strength distributed, and which members have the strongest connections?" | `get_location_context`, `list_quadrant_members`                      |
| "Which coach do the most members rely on, and if they left, which members would lose their strongest connection?"  | `get_staff_concentration`, `simulate_departure`, `get_staff_context` |
| "Look up one of those members by name."                                                                            | `entity_lookup`                                                      |
| "Show this studio's business context."                                                                             | `get_business_context`                                               |
| "What skills are available, and what does the at-risk-members skill do?"                                           | `list_skills`, `get_skill`                                           |
| "Save a view of monthly revenue called `monthly_revenue`."                                                         | `create_view` (write — needs operations)                             |
| "Add a note to studio memory: 'reviewer test'."                                                                    | `memory_create` (write — needs operations)                           |
| "How do I connect Stripe?"                                                                                         | `search_docs`, `get_doc` (product docs, no studio data)              |

These relationship-context tools describe **who a member or staff member is connected to and how healthy those connections are** — they are not churn predictors. Spotting members who have started dropping off is the job of the **attention signals** (`get_open_signals`, the sense layer future Whispers will surface) and the separate **at-risk-members skill**, both of which read from, but are distinct from, the relationship graph.

The relationship-context tools each take a required `purpose` that scopes the response — analysis purposes anonymise members, service-delivery purposes withhold the connection-strength analytics, and a denied purpose returns an empty result rather than an error.

The full surface with read-only/destructive flags is in the [tool reference](/for-developers/tools). Tools called above your token's permission level return a clear `403` and reveal nothing — try an `analytics` token against a names tool to see redaction in action.

## What you won't be able to do (by design)

* Move money, refund, or transfer — there are no such tools.
* Generate images/audio/video — no such tools.
* Reach another studio's data — tenancy is a physical database boundary, derived from the token, never a parameter.
* Run arbitrary writes — `execute_query` is `SELECT`/`WITH`-only; mutations go through specific, scope-gated, audited tools.

See [Security & data handling](/trust-and-legal/security) for the full posture.

## Documentation you may want

* [Privacy policy](/trust-and-legal/privacy) · [Terms](/trust-and-legal/terms) · [Security](/trust-and-legal/security) · [Responsible disclosure](/trust-and-legal/disclosure)
* [Owner getting-started](/get-started/start) · [What you can ask](/get-started/ask)
* [Tool reference](/for-developers/tools) · [Schema reference](/for-developers/schema)

Questions during review: **<support@kula.digital>**.


