Loopwise Docs
Guides

AI Agent Integration

A cookbook for AI-driven integrators connecting Loopwise OAuth to their app, with role-aware onboarding via standard OIDC claims.

This guide is written for AI agents (or the engineers directing them) building a new integration against a Loopwise school. It covers the shortest path from "I have a client_id and client_secret" to a working OAuth login that knows the user's school and role.

If you are looking for the protocol-level details, read OAuth Quickstart first. This page focuses on the consumer pattern that's productive for agentic workflows: declarative mapProfileToUser, no extra GraphQL roundtrips, and a stable "primary role" contract.

What you get out of /api/oauth/userinfo

A standard OIDC /api/oauth/userinfo call against Loopwise returns:

{
  "sub":            "550e8400-e29b-41d4-a716-446655440000",
  "name":           "Jane Doe",
  "email":          "jane@example.com",
  "email_verified": true,
  "org_id":         "7a1e9d5f-3c2b-4a8e-9d3f-0c2b6e8d4f1a",
  "roles":          ["owner", "teacher"]
}

Two of these are Loopwise-specific extension claims advertised in /.well-known/oauth-authorization-server:

  • org_id — the school identifier the access token is bound to. Use it as the tenant key when you store the OAuth user in your own database.
  • roles — the user's roles on that school, ordered by privilege (and may be an empty array if the user has no roles on the bound school). When the array is non-empty, roles[0] is contractually the highest-privilege role and is the recommended source for onboarding-time role mapping.

The full claim schema and ordering rules live in the UserInfo endpoint reference.

Pattern A: Next.js + better-auth via @loopwise/admin-sdk

This is the recommended path if your integration is a Next.js app. The SDK ships a branded better-auth provider that pre-fills Loopwise discovery and PKCE.

import { betterAuth } from 'better-auth';
import { loopwise } from '@loopwise/admin-sdk/better-auth';

// Map "Loopwise role" → "your app's role" once, at sign-up.
const ROLE_MAP: Record<string, string> = {
  owner:              'admin',
  manager:            'admin',
  admin:              'admin',
  teacher:            'instructor',
  teaching_assistant: 'instructor',
  student:            'member',
};

export const auth = betterAuth({
  // ...your base config
  plugins: [
    loopwise({
      clientId:     process.env.LOOPWISE_CLIENT_ID!,
      clientSecret: process.env.LOOPWISE_CLIENT_SECRET!,
      baseURL:      process.env.LOOPWISE_BASE_URL!,

      mapProfileToUser: (profile) => {
        // `roles[0]` is the highest-privilege role the user holds —
        // when the array is non-empty. Both `org_id` and `roles` may be
        // absent for platform-managed tokens with no school context; we
        // keep `schoolId` optional and fall back to "member" for `role`.
        const primary = (profile.roles as string[] | undefined)?.[0];
        return {
          teachifyUserId: profile.sub as string,
          schoolId:       profile.org_id as string | undefined,
          role: primary ? (ROLE_MAP[primary] ?? 'member') : 'member',
        };
      },
    }),
  ],
});

With this in place, your application's user table is populated from a single declarative callback — no follow-up GraphQL call, no scope negotiation, no custom flow.

You'll need to declare each field you persist (teachifyUserId, schoolId, role) as an additionalField in your better-auth config and add the matching columns to your schema (e.g. in Prisma):

betterAuth({
  user: {
    additionalFields: {
      teachifyUserId: { type: 'string', required: false },
      schoolId:       { type: 'string', required: false },
      role:           { type: 'string', required: false },
    },
  },
  // ...
});

For the complete end-to-end example, see the nextjs-admin-sdk template.

Pattern B: Raw OAuth (no SDK)

If you're not on better-auth — say you're writing a Python service, a CLI, or a custom backend — the contract is identical, just plain OAuth + a single HTTP call.

import requests

# Step 1: exchange the authorization code for tokens (already done).
access_token = "..."

# Step 2: fetch user identity.
resp = requests.get(
    "https://your-school-domain.com/api/oauth/userinfo",
    headers={"Authorization": f"Bearer {access_token}"},
)
profile = resp.json()

teachify_user_id = profile["sub"]
school_id        = profile.get("org_id")
primary_role     = (profile.get("roles") or ["member"])[0]

Store these three values against your application's user record. That's the onboarding write you need.

Mapping Loopwise roles to your app's role model

Loopwise's built-in roles, ordered by privilege:

Loopwise roleTypical app-side mappingNotes
ownerAdmin / OwnerSchool founder. One per school.
managerAdminBusiness admin. Same admin-gate as owner.
adminAdminElevated permissions overlay.
teacherInstructor / EditorContent creator.
teaching_assistantInstructor (limited)Supports teachers, limited write surface.
studentMember / LearnerThe default end-user.

Tenant-defined custom roles (created from the school's admin UI) appear by name after the built-in roles, sorted alphabetically. Map them in your ROLE_MAP table the same way:

const ROLE_MAP: Record<string, string> = {
  owner: 'admin',
  // ...
  '行銷': 'marketing',
  'finance':  'finance',
};

If ROLE_MAP[profile.roles[0]] is undefined, fall back to a safe default ('member' is usually right).

Edge cases

A user with no org_id / roles

Both claims appear only when the access token carries a school binding — true for every standard OAuth login (the authorize-time school picker writes school_id onto the token). If you see a token without these claims, it usually means a platform-managed personal-access flow that hasn't yet bound to a school. Handle this as "no school context yet" rather than as a failure.

A user who is staff on multiple schools

A Loopwise user can be staff on more than one school (e.g. a contractor with teacher on School A and manager on School B). The OAuth token binds to one school — the one selected at authorize-time — and org_id / roles reflect that single school.

If your integration needs the user's full school list, request the account:read scope and call the Account API:

query {
  viewer {
    id
    schools {
      schoolId
      subdomain
      name
      roles
    }
  }
}

This is rarely needed for the agent-onboarding case, but it's there.

Role changes after sign-in

/api/oauth/userinfo is a live database lookup — if a user is demoted from owner to manager after they've signed in, the next userinfo call reflects the new state. Your cached role column does not update automatically; refresh it on relevant events (re-login, role-change webhook if you've subscribed, or a scheduled sync).

Common mistakes

  • Treating roles as a permissions list. It's an identity claim (which roles this user holds), not an authorization claim (what this token can do). The token's actual capabilities are governed by the OAuth scopes — see Scopes.
  • Assuming roles[0] is always defined. A school-bound token whose user has no roles on that school returns roles: []. (Separately: tokens with no school context at all omit both org_id and roles entirely.) Handle both — missing key and empty array — and default to your safest fallback (e.g. 'member').
  • Requesting account:read unnecessarily. If you only need "the user's primary role on the school they just logged into," openid is enough. Adding scopes you don't need triggers unnecessary consent surface and slows down adoption.
  • Hardcoding roles[0] === 'owner' checks. Use a ROLE_MAP lookup so tenant-defined custom roles still resolve to a sensible default rather than silently falling through.

On this page