mobieusKnow mobieusHealth: Loopa Coach History #497
Author
Patrick Bass
Submitted
Aug 20, 2026 8:10am
Summary
Loopa Coach on the phone apps: join-later, the access-log context, and coach push
+ # mobieusHealth: Loopa Coach
+
**Loopa Coach** lets an approved human coach hold a roster of members inside a tenant: a private forum, a per-field view of what each client has chosen to share, short recommendations, and meal plans the member accepts or declines. It is distinct from the [Help Me coach](/know/mobieushealth-help-me-coach), which is a member talking to a model. This one is a person.
It is gated on the tenant feature flag **`loopa_coach`** and requires mobieusHealth. Default off.
This is a wellness feature, not medical advice, and the product says so on every surface a coach touches.
## Two things must both be true
A member is a coach only when **the tenant has the flag on** and **that member holds a coach record**. Turning the flag on does not make anybody a coach; it makes the surfaces exist for the ones who are.
+ Coaches are approved by hand. The application, the credential check, the commission terms and the payouts all live in the affiliate system, not in the tenant — an approval there is picked up by the tenant host and provisioned automatically, usually within the hour.
Coaches are approved by hand. The application, the credential check, the commission terms and the payouts all live in the affiliate system, not in the tenant. An approval there is picked up by the tenant host and provisioned automatically, usually within the hour.
## Identity is required. A certification is not.
Two different marks, answering two different questions, and conflating them is the mistake to avoid.
+ **mobieusVerified** — a government ID and a liveness check, reviewed by a person. **Required**, and **comped**: it is normally a $5/month subscription and coaches are not charged for it.
**mobieusVerified** is a government ID and a liveness check, reviewed by a person. **Required**, and **comped**: it is normally a $5/month subscription and coaches are not charged for it.
+ The comp is automatic. When a coach loads `/health/coach-portal/verify`, the tenant calls `POST /api/verification/comp` on the admin plane, which writes a `verification_subscriptions` row with `source='comp'` and a `comp:` sentinel in place of a Stripe id. That row is what makes them verifiable without paying — `VerificationService::status()` answers from that table alone, so an approved verification with no subscription reads as unverified no matter how it was reviewed.
The comp is automatic. When a coach loads `/health/coach-portal/verify`, the tenant calls `POST /api/verification/comp` on the admin plane, which writes a `verification_subscriptions` row with `source='comp'` and a `comp:` sentinel in place of a Stripe id. That row is what makes them verifiable without paying. `VerificationService::status()` answers from that table alone, so an approved verification with no subscription reads as unverified no matter how it was reviewed.
+ **A comp is not an approval.** `status()` requires an acceptable subscription AND an approved verification, so the coach still submits a government ID and a selfie video and a person still reviews both. The comp removes the payment and nothing else. Somebody already paying is left alone rather than converted — the endpoint answers `already_paid` — because cancelling their billing behind their back is not this endpoint's business. The comp ends in `LoopaCoachProvisioning::terminate()`. A coach cannot reach their roster until it passes: the portal parks them on `/health/coach-portal/verify` after they accept the agreement. It renders as the blue check next to their name across forums, chat and messages. The gate fails **closed** if the verification service is unreachable, which is the opposite of how the badge itself degrades — a badge that fails to render is cosmetic, a coach who reaches a roster of health data because a status call timed out is not.
**A comp is not an approval.** `status()` requires an acceptable subscription AND an approved verification, so the coach still submits a government ID and a selfie video and a person still reviews both. The comp removes the payment and nothing else. Somebody already paying is left alone rather than converted (the endpoint answers `already_paid`), because cancelling their billing behind their back is not this endpoint's business. The comp ends in `LoopaCoachProvisioning::terminate()`. A coach cannot reach their roster until it passes: the portal parks them on `/health/coach-portal/verify` after they accept the agreement. It renders as the blue check next to their name across forums, chat and messages. The gate fails **closed** if the verification service is unreachable, which is the opposite of how the badge itself degrades. A badge that fails to render is cosmetic. A coach who reaches a roster of health data because a status call timed out is not.
+ **A credential** — a licence or certification. **Optional.** Coaches do not have to be certified, and "no formal certification" is a first-class answer on the application rather than a blank. If one is claimed it is checked with the issuing body and only then does anything display it. An unverified claim renders nowhere, in any state.
**A credential** is a license or certification. **Optional.** Coaches do not have to be certified, and "no formal certification" is a first-class answer on the application rather than a blank. If one is claimed it is checked with the issuing body and only then does anything display it. An unverified claim renders nowhere, in any state.
## What the member controls
Everything. A member who joined through a coach's link finds **Settings → Your coach**, where every category starts **off**:
| Category | What the coach sees |
|---|---|
| Weight and body measurements | A 30-day trend and the latest figure |
| Activity and workouts | The date of the last entry |
| Fasting windows | The date of the last entry |
| Food log, calories and macros | The date of the last entry |
| Cycle tracking | The date of the last entry |
| Supplements and medications | The date of the last entry |
| Streaks | How many consecutive days carry a food log |
Summaries, not entries. A coach does not read the member's diary.
+ **A category that is off is absent** — not blurred, not greyed, not a placeholder with a count, because a count of what was withheld is itself a disclosure.
**A category that is off is absent.** Not blurred, not grayed, not a placeholder with a count, because a count of what was withheld is itself a disclosure.
+ **Switching a category off closes the history with it.** Permission is checked at query time on every read, so revocation is not "from now on" — a category switched off is closed even for what the coach could already see. There is no cached snapshot anywhere.
**Switching a category off closes the history with it.** Permission is checked at query time on every read, so revocation is not "from now on". A category switched off is closed even for what the coach could already see. There is no cached snapshot anywhere.
**Every allowed read is logged** (coach, member, category, hour) and the member can ask for that record.
Blocking the coach severs everything at once, and leaving the coach ends the roster row. Neither changes the member's subscription, price, or forum access.
## What the coach gets
* **A private forum**, created at provisioning, hidden from everyone not on their roster, with the coach as owner and moderator. Two posts a day.
* **A roster**, showing only what each client shares.
+ * **Recommendations** — short messages, three per client per day.
* **Recommendations.** Short messages, three per client per day.
* **Meal plans** built against the *client's* targets, diet style and exclusions. The coach picks the length, never the food, and cannot inject prompt context. A plan that violates the client's exclusion list is rejected, not flagged. It arrives as an offer; nothing enters the member's planner until they accept.
* **Private notes**, never visible to the client, deleted when the coach is terminated or the client closes their account.
Coach surfaces are invisible to everyone else: `/health/coach-portal` returns the same 404 a non-existent route returns for any member who is not an active coach.
## Entitlement
An active coach record grants Premium for as long as it lasts. No seat row is written, so it never collides with the coach's own store purchase and is removed by a single status change on termination.
## The verified badge
A credential **claim** and a credential **check** are two different columns. Nothing renders a coach as verified until a person has confirmed the credential with whoever issued it. Removing a verification takes the badge off every client screen without ending the roster.
## Turning it on
Platform admin → the tenant's feature flags → **Loopa Coach**. It needs mobieusHealth on the same tenant.
See also: [mobieusHealth](/know/mobieus-health) · [mobieusHealth: Help Me coach](/know/mobieushealth-help-me-coach)
---
+ ## Addendum, 20 August 2026 — the phone apps
## Addendum, 20 August 2026 (the phone apps)
Loopa Coach shipped on 18 August as two web pages. As of this release everything below is
also in the iOS and Android apps, and four things changed underneath. Worth reading before
you answer a ticket about any of it.
### Where a member finds it now
+ **More → Preferences → Your coach** for a client. **More → Coaching** for a coach, in its
+ own group directly under Membership. A member with neither role sees neither row — not
+ greyed, not a teaser. If a member says "I can't find it", the first question is whether they
**You → Preferences → Your coach** for a client. **You → Coaching** for a coach, in its
own group directly under Membership. A member with neither role sees neither row. Not
grayed, not a teaser. If a member says "I can't find it", the first question is whether they
have a coach at all, not where the button is.
The apps ask `GET /api/v1/coach/me` on every foreground and never remember the answer. That
is deliberate: a termination or a member leaving on another device has no push behind it, so
the app finds out when some other coach route returns 404 and rebuilds the menu. If a member
reports the row lingering, ask them to background and reopen the app.
### A member can now join a coach WITHOUT signing up
Until now the only way onto a roster was to create an account through a coach's link.
`POST /api/v1/coach/join` is the other half, and it is what a coach's link now does for an
existing member.
+ `loopa_coach_clients.enrolled_by_member` records which of the two happened — `0` at signup,
`loopa_coach_clients.enrolled_by_member` records which of the two happened: `0` at signup,
`1` for a member who joined later. Every row that existed before this release is `0`, which
is correct.
**The join endpoint is deliberately not an enumeration oracle.** A code that does not exist,
a code belonging to an ordinary affiliate rather than a coach, and a code whose coach has
been terminated all return the byte-identical error. If a coach reports that "the link says
invalid", you cannot tell which of the three it is from the response and neither can anybody
+ else — check `loopa_coaches.status` and `affiliate_code` directly.
else. Check `loopa_coaches.status` and `affiliate_code` directly.
Re-joining a coach a member previously left works and clears `ended_at`, but it does **not**
resurrect their old share grants: `revoked_at` stays set, so they come back with every
+ category off. That is the answer to "I rejoined and my coach can't see anything" — it is
category off. That is the answer to "I rejoined and my coach can't see anything". It is
working as designed and they need to switch the categories back on.
### The access log now distinguishes a glance from a read
`loopa_coach_access_log.context` is new, and it is in the unique key, so the two coalesce
into separate hourly buckets.
+ * `roster_summary` — the coach opened their client list and this member was on it.
+ * `client_detail` — the coach opened this member and read them.
* `roster_summary`. The coach opened their client list and this member was on it.
* `client_detail`. The coach opened this member and read them.
Before this, `roster()` wrote the same rows the single-client page did, so a coach opening
their roster nine times produced a record saying they had looked at that member's weight
nine times. **Every row that predates this release is stamped `client_detail`**, which is the
+ safe direction — it claims the closer read for history we cannot re-derive, never the
safe direction. It claims the closer read for history we cannot re-derive, never the
reverse. If a member queries a figure from before 20 August, that is why it may look high.
Members can now read this themselves at **Who looked, and when**. They no longer have to
write to privacy@ for it, and the member KB has been updated to say so.
### Coach notifications can now actually reach a phone
`coach_*` notification types were not mapped in `NotificationPrefs`, so every send logged an
+ unmapped-type warning and **could never be pushed** — "your coach sent you a meal plan"
unmapped-type warning and **could never be pushed**, so "your coach sent you a meal plan"
only appeared if the member happened to open the app. They are now per-type, default **on**
for in-app and email and **off** for push, which is opt-in like buddy and challenge
notifications. A member who wants coach pushes has to enable them in notification
preferences; that is not a bug.
Each type carries a `loopa://` deep link so a tap lands on the right screen. Both apps check
the role on arrival and fall back silently to the More tab if it no longer applies, so a
push that outlives a termination opens nothing.
### Two things that did NOT change, and will not
**No money in either app.** No commission, balance, earnings or payout figure appears
anywhere in iOS or Android (Apple 3.1.1). The invite screen shows the link and points at the
web partner portal. A coach asking "where are my earnings in the app" should be sent to the
portal; there is nothing to find and nothing coming.
**No coach-to-client direct messages.** Three recommendations per client per day at 600
characters, two forum posts per day. A coach asking for the limit to be raised is asking for
a product change, not a support action.
### Roster performance
`readableFieldsBatch()` replaced several hundred round trips per roster paint with a bounded
+ number. It makes every check the per-field path makes — coach status, block in either
+ direction, live roster membership, live grant — once each, inside one request. It is **not**
number. It makes every check the per-field path makes (coach status, block in either
direction, live roster membership, live grant) once each, inside one request. It is **not**
a cached consent decision and must never become one.
### Display names
The roster used to build names in SQL with a fallback to the raw `username`. This platform
lets a member register with their email address as their username, so a coach's roster could
print their clients' email addresses. Names now go through `UsernameHelper::displayName()`,
which strips the domain. If a coach mentions having seen an email address, it was real, it
is fixed, and it is worth escalating so the affected members can be told.

mobieusHealth: Loopa Coach

Loopa Coach lets an approved human coach hold a roster of members inside a tenant: a private forum, a per-field view of what each client has chosen to share, short recommendations, and meal plans the member accepts or declines. It is distinct from the Help Me coach, which is a member talking to a model. This one is a person.

It is gated on the tenant feature flag loopa_coach and requires mobieusHealth. Default off.

This is a wellness feature, not medical advice, and the product says so on every surface a coach touches.

Two things must both be true

A member is a coach only when the tenant has the flag on and that member holds a coach record. Turning the flag on does not make anybody a coach; it makes the surfaces exist for the ones who are.

Coaches are approved by hand. The application, the credential check, the commission terms and the payouts all live in the affiliate system, not in the tenant — an approval there is picked up by the tenant host and provisioned automatically, usually within the hour.

Identity is required. A certification is not.

Two different marks, answering two different questions, and conflating them is the mistake to avoid.

mobieusVerified — a government ID and a liveness check, reviewed by a person. Required, and comped: it is normally a $5/month subscription and coaches are not charged for it.

The comp is automatic. When a coach loads /health/coach-portal/verify, the tenant calls POST /api/verification/comp on the admin plane, which writes a verification_subscriptions row with source='comp' and a comp: sentinel in place of a Stripe id. That row is what makes them verifiable without paying — VerificationService::status() answers from that table alone, so an approved verification with no subscription reads as unverified no matter how it was reviewed.

A comp is not an approval. status() requires an acceptable subscription AND an approved verification, so the coach still submits a government ID and a selfie video and a person still reviews both. The comp removes the payment and nothing else. Somebody already paying is left alone rather than converted — the endpoint answers already_paid — because cancelling their billing behind their back is not this endpoint's business. The comp ends in LoopaCoachProvisioning::terminate(). A coach cannot reach their roster until it passes: the portal parks them on /health/coach-portal/verify after they accept the agreement. It renders as the blue check next to their name across forums, chat and messages. The gate fails closed if the verification service is unreachable, which is the opposite of how the badge itself degrades — a badge that fails to render is cosmetic, a coach who reaches a roster of health data because a status call timed out is not.

A credential — a licence or certification. Optional. Coaches do not have to be certified, and "no formal certification" is a first-class answer on the application rather than a blank. If one is claimed it is checked with the issuing body and only then does anything display it. An unverified claim renders nowhere, in any state.

What the member controls

Everything. A member who joined through a coach's link finds Settings → Your coach, where every category starts off:

Category What the coach sees
Weight and body measurements A 30-day trend and the latest figure
Activity and workouts The date of the last entry
Fasting windows The date of the last entry
Food log, calories and macros The date of the last entry
Cycle tracking The date of the last entry
Supplements and medications The date of the last entry
Streaks How many consecutive days carry a food log

Summaries, not entries. A coach does not read the member's diary.

A category that is off is absent — not blurred, not greyed, not a placeholder with a count, because a count of what was withheld is itself a disclosure.

Switching a category off closes the history with it. Permission is checked at query time on every read, so revocation is not "from now on" — a category switched off is closed even for what the coach could already see. There is no cached snapshot anywhere.

Every allowed read is logged (coach, member, category, hour) and the member can ask for that record.

Blocking the coach severs everything at once, and leaving the coach ends the roster row. Neither changes the member's subscription, price, or forum access.

What the coach gets

  • A private forum, created at provisioning, hidden from everyone not on their roster, with the coach as owner and moderator. Two posts a day.
  • A roster, showing only what each client shares.
  • Recommendations — short messages, three per client per day.
  • Meal plans built against the client's targets, diet style and exclusions. The coach picks the length, never the food, and cannot inject prompt context. A plan that violates the client's exclusion list is rejected, not flagged. It arrives as an offer; nothing enters the member's planner until they accept.
  • Private notes, never visible to the client, deleted when the coach is terminated or the client closes their account.

Coach surfaces are invisible to everyone else: /health/coach-portal returns the same 404 a non-existent route returns for any member who is not an active coach.

Entitlement

An active coach record grants Premium for as long as it lasts. No seat row is written, so it never collides with the coach's own store purchase and is removed by a single status change on termination.

The verified badge

A credential claim and a credential check are two different columns. Nothing renders a coach as verified until a person has confirmed the credential with whoever issued it. Removing a verification takes the badge off every client screen without ending the roster.

Turning it on

Platform admin → the tenant's feature flags → Loopa Coach. It needs mobieusHealth on the same tenant.

See also: mobieusHealth · mobieusHealth: Help Me coach


Addendum, 20 August 2026 — the phone apps

Loopa Coach shipped on 18 August as two web pages. As of this release everything below is also in the iOS and Android apps, and four things changed underneath. Worth reading before you answer a ticket about any of it.

Where a member finds it now

More → Preferences → Your coach for a client. More → Coaching for a coach, in its own group directly under Membership. A member with neither role sees neither row — not greyed, not a teaser. If a member says "I can't find it", the first question is whether they have a coach at all, not where the button is.

The apps ask GET /api/v1/coach/me on every foreground and never remember the answer. That is deliberate: a termination or a member leaving on another device has no push behind it, so the app finds out when some other coach route returns 404 and rebuilds the menu. If a member reports the row lingering, ask them to background and reopen the app.

A member can now join a coach WITHOUT signing up

Until now the only way onto a roster was to create an account through a coach's link. POST /api/v1/coach/join is the other half, and it is what a coach's link now does for an existing member.

loopa_coach_clients.enrolled_by_member records which of the two happened — 0 at signup, 1 for a member who joined later. Every row that existed before this release is 0, which is correct.

The join endpoint is deliberately not an enumeration oracle. A code that does not exist, a code belonging to an ordinary affiliate rather than a coach, and a code whose coach has been terminated all return the byte-identical error. If a coach reports that "the link says invalid", you cannot tell which of the three it is from the response and neither can anybody else — check loopa_coaches.status and affiliate_code directly.

Re-joining a coach a member previously left works and clears ended_at, but it does not resurrect their old share grants: revoked_at stays set, so they come back with every category off. That is the answer to "I rejoined and my coach can't see anything" — it is working as designed and they need to switch the categories back on.

The access log now distinguishes a glance from a read

loopa_coach_access_log.context is new, and it is in the unique key, so the two coalesce into separate hourly buckets.

  • roster_summary — the coach opened their client list and this member was on it.
  • client_detail — the coach opened this member and read them.

Before this, roster() wrote the same rows the single-client page did, so a coach opening their roster nine times produced a record saying they had looked at that member's weight nine times. Every row that predates this release is stamped client_detail, which is the safe direction — it claims the closer read for history we cannot re-derive, never the reverse. If a member queries a figure from before 20 August, that is why it may look high.

Members can now read this themselves at Who looked, and when. They no longer have to write to privacy@ for it, and the member KB has been updated to say so.

Coach notifications can now actually reach a phone

coach_* notification types were not mapped in NotificationPrefs, so every send logged an unmapped-type warning and could never be pushed — "your coach sent you a meal plan" only appeared if the member happened to open the app. They are now per-type, default on for in-app and email and off for push, which is opt-in like buddy and challenge notifications. A member who wants coach pushes has to enable them in notification preferences; that is not a bug.

Each type carries a loopa:// deep link so a tap lands on the right screen. Both apps check the role on arrival and fall back silently to the More tab if it no longer applies, so a push that outlives a termination opens nothing.

Two things that did NOT change, and will not

No money in either app. No commission, balance, earnings or payout figure appears anywhere in iOS or Android (Apple 3.1.1). The invite screen shows the link and points at the web partner portal. A coach asking "where are my earnings in the app" should be sent to the portal; there is nothing to find and nothing coming.

No coach-to-client direct messages. Three recommendations per client per day at 600 characters, two forum posts per day. A coach asking for the limit to be raised is asking for a product change, not a support action.

Roster performance

readableFieldsBatch() replaced several hundred round trips per roster paint with a bounded number. It makes every check the per-field path makes — coach status, block in either direction, live roster membership, live grant — once each, inside one request. It is not a cached consent decision and must never become one.

Display names

The roster used to build names in SQL with a fallback to the raw username. This platform lets a member register with their email address as their username, so a coach's roster could print their clients' email addresses. Names now go through UsernameHelper::displayName(), which strips the domain. If a coach mentions having seen an email address, it was real, it is fixed, and it is worth escalating so the affected members can be told.

# mobieusHealth: Loopa Coach

**Loopa Coach** lets an approved human coach hold a roster of members inside a tenant: a private forum, a per-field view of what each client has chosen to share, short recommendations, and meal plans the member accepts or declines. It is distinct from the [Help Me coach](/know/mobieushealth-help-me-coach), which is a member talking to a model. This one is a person.

It is gated on the tenant feature flag **`loopa_coach`** and requires mobieusHealth. Default off.

This is a wellness feature, not medical advice, and the product says so on every surface a coach touches.

## Two things must both be true

A member is a coach only when **the tenant has the flag on** and **that member holds a coach record**. Turning the flag on does not make anybody a coach; it makes the surfaces exist for the ones who are.

Coaches are approved by hand. The application, the credential check, the commission terms and the payouts all live in the affiliate system, not in the tenant — an approval there is picked up by the tenant host and provisioned automatically, usually within the hour.

## Identity is required. A certification is not.

Two different marks, answering two different questions, and conflating them is the mistake to avoid.

**mobieusVerified** — a government ID and a liveness check, reviewed by a person. **Required**, and **comped**: it is normally a $5/month subscription and coaches are not charged for it.

The comp is automatic. When a coach loads `/health/coach-portal/verify`, the tenant calls `POST /api/verification/comp` on the admin plane, which writes a `verification_subscriptions` row with `source='comp'` and a `comp:` sentinel in place of a Stripe id. That row is what makes them verifiable without paying — `VerificationService::status()` answers from that table alone, so an approved verification with no subscription reads as unverified no matter how it was reviewed.

**A comp is not an approval.** `status()` requires an acceptable subscription AND an approved verification, so the coach still submits a government ID and a selfie video and a person still reviews both. The comp removes the payment and nothing else. Somebody already paying is left alone rather than converted — the endpoint answers `already_paid` — because cancelling their billing behind their back is not this endpoint's business. The comp ends in `LoopaCoachProvisioning::terminate()`. A coach cannot reach their roster until it passes: the portal parks them on `/health/coach-portal/verify` after they accept the agreement. It renders as the blue check next to their name across forums, chat and messages. The gate fails **closed** if the verification service is unreachable, which is the opposite of how the badge itself degrades — a badge that fails to render is cosmetic, a coach who reaches a roster of health data because a status call timed out is not.

**A credential** — a licence or certification. **Optional.** Coaches do not have to be certified, and "no formal certification" is a first-class answer on the application rather than a blank. If one is claimed it is checked with the issuing body and only then does anything display it. An unverified claim renders nowhere, in any state.

## What the member controls

Everything. A member who joined through a coach's link finds **Settings → Your coach**, where every category starts **off**:

| Category | What the coach sees |
|---|---|
| Weight and body measurements | A 30-day trend and the latest figure |
| Activity and workouts | The date of the last entry |
| Fasting windows | The date of the last entry |
| Food log, calories and macros | The date of the last entry |
| Cycle tracking | The date of the last entry |
| Supplements and medications | The date of the last entry |
| Streaks | How many consecutive days carry a food log |

Summaries, not entries. A coach does not read the member's diary.

**A category that is off is absent** — not blurred, not greyed, not a placeholder with a count, because a count of what was withheld is itself a disclosure.

**Switching a category off closes the history with it.** Permission is checked at query time on every read, so revocation is not "from now on" — a category switched off is closed even for what the coach could already see. There is no cached snapshot anywhere.

**Every allowed read is logged** (coach, member, category, hour) and the member can ask for that record.

Blocking the coach severs everything at once, and leaving the coach ends the roster row. Neither changes the member's subscription, price, or forum access.

## What the coach gets

* **A private forum**, created at provisioning, hidden from everyone not on their roster, with the coach as owner and moderator. Two posts a day.
* **A roster**, showing only what each client shares.
* **Recommendations** — short messages, three per client per day.
* **Meal plans** built against the *client's* targets, diet style and exclusions. The coach picks the length, never the food, and cannot inject prompt context. A plan that violates the client's exclusion list is rejected, not flagged. It arrives as an offer; nothing enters the member's planner until they accept.
* **Private notes**, never visible to the client, deleted when the coach is terminated or the client closes their account.

Coach surfaces are invisible to everyone else: `/health/coach-portal` returns the same 404 a non-existent route returns for any member who is not an active coach.

## Entitlement

An active coach record grants Premium for as long as it lasts. No seat row is written, so it never collides with the coach's own store purchase and is removed by a single status change on termination.

## The verified badge

A credential **claim** and a credential **check** are two different columns. Nothing renders a coach as verified until a person has confirmed the credential with whoever issued it. Removing a verification takes the badge off every client screen without ending the roster.

## Turning it on

Platform admin → the tenant's feature flags → **Loopa Coach**. It needs mobieusHealth on the same tenant.

See also: [mobieusHealth](/know/mobieus-health) · [mobieusHealth: Help Me coach](/know/mobieushealth-help-me-coach)

---

## Addendum, 20 August 2026 — the phone apps

Loopa Coach shipped on 18 August as two web pages. As of this release everything below is
also in the iOS and Android apps, and four things changed underneath. Worth reading before
you answer a ticket about any of it.

### Where a member finds it now

**More → Preferences → Your coach** for a client. **More → Coaching** for a coach, in its
own group directly under Membership. A member with neither role sees neither row — not
greyed, not a teaser. If a member says "I can't find it", the first question is whether they
have a coach at all, not where the button is.

The apps ask `GET /api/v1/coach/me` on every foreground and never remember the answer. That
is deliberate: a termination or a member leaving on another device has no push behind it, so
the app finds out when some other coach route returns 404 and rebuilds the menu. If a member
reports the row lingering, ask them to background and reopen the app.

### A member can now join a coach WITHOUT signing up

Until now the only way onto a roster was to create an account through a coach's link.
`POST /api/v1/coach/join` is the other half, and it is what a coach's link now does for an
existing member.

`loopa_coach_clients.enrolled_by_member` records which of the two happened — `0` at signup,
`1` for a member who joined later. Every row that existed before this release is `0`, which
is correct.

**The join endpoint is deliberately not an enumeration oracle.** A code that does not exist,
a code belonging to an ordinary affiliate rather than a coach, and a code whose coach has
been terminated all return the byte-identical error. If a coach reports that "the link says
invalid", you cannot tell which of the three it is from the response and neither can anybody
else — check `loopa_coaches.status` and `affiliate_code` directly.

Re-joining a coach a member previously left works and clears `ended_at`, but it does **not**
resurrect their old share grants: `revoked_at` stays set, so they come back with every
category off. That is the answer to "I rejoined and my coach can't see anything" — it is
working as designed and they need to switch the categories back on.

### The access log now distinguishes a glance from a read

`loopa_coach_access_log.context` is new, and it is in the unique key, so the two coalesce
into separate hourly buckets.

* `roster_summary` — the coach opened their client list and this member was on it.
* `client_detail` — the coach opened this member and read them.

Before this, `roster()` wrote the same rows the single-client page did, so a coach opening
their roster nine times produced a record saying they had looked at that member's weight
nine times. **Every row that predates this release is stamped `client_detail`**, which is the
safe direction — it claims the closer read for history we cannot re-derive, never the
reverse. If a member queries a figure from before 20 August, that is why it may look high.

Members can now read this themselves at **Who looked, and when**. They no longer have to
write to privacy@ for it, and the member KB has been updated to say so.

### Coach notifications can now actually reach a phone

`coach_*` notification types were not mapped in `NotificationPrefs`, so every send logged an
unmapped-type warning and **could never be pushed** — "your coach sent you a meal plan"
only appeared if the member happened to open the app. They are now per-type, default **on**
for in-app and email and **off** for push, which is opt-in like buddy and challenge
notifications. A member who wants coach pushes has to enable them in notification
preferences; that is not a bug.

Each type carries a `loopa://` deep link so a tap lands on the right screen. Both apps check
the role on arrival and fall back silently to the More tab if it no longer applies, so a
push that outlives a termination opens nothing.

### Two things that did NOT change, and will not

**No money in either app.** No commission, balance, earnings or payout figure appears
anywhere in iOS or Android (Apple 3.1.1). The invite screen shows the link and points at the
web partner portal. A coach asking "where are my earnings in the app" should be sent to the
portal; there is nothing to find and nothing coming.

**No coach-to-client direct messages.** Three recommendations per client per day at 600
characters, two forum posts per day. A coach asking for the limit to be raised is asking for
a product change, not a support action.

### Roster performance

`readableFieldsBatch()` replaced several hundred round trips per roster paint with a bounded
number. It makes every check the per-field path makes — coach status, block in either
direction, live roster membership, live grant — once each, inside one request. It is **not**
a cached consent decision and must never become one.

### Display names

The roster used to build names in SQL with a fallback to the raw `username`. This platform
lets a member register with their email address as their username, so a coach's roster could
print their clients' email addresses. Names now go through `UsernameHelper::displayName()`,
which strips the domain. If a coach mentions having seen an email address, it was real, it
is fixed, and it is worth escalating so the affected members can be told.