Auth & Roles
Remy apps can have their own users. Auth is opt-in: configure it in the manifest, define a user table, and build your own login UI. The platform handles verification codes (email/SMS), cookie-based sessions, and role enforcement.
Apps without auth config use anonymous guest sessions. Add auth only when the app needs to identify users or restrict access.
#Manifest Config
{
"auth": {
"enabled": true,
"methods": ["email-code", "sms-code"],
"table": {
"name": "users",
"columns": {
"email": "email",
"phone": "phone",
"roles": "roles"
}
}
},
"roles": [
{ "id": "vendor", "name": "Vendor" },
{ "id": "buyer", "name": "Buyer" },
{ "id": "admin", "name": "Admin" }
]
}#Auth Fields
| Field | Type | Required | Description |
|---|---|---|---|
auth.enabled | boolean | Yes | true to enable auth |
auth.methods | string[] | Yes | "email-code", "sms-code", and/or "remy" (platform-delegated sign-in — see below). At least one. |
auth.table.name | string | Yes | Name of the defineTable table for user records |
auth.table.columns.email | string | If email-code | Column name for email (read-only from code) |
auth.table.columns.phone | string | If sms-code | Column name for phone (read-only from code) |
auth.table.columns.roles | string | No | Column name for roles array (bidirectional sync) |
#Roles
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Kebab-case identifier (used in code: auth.requireRole('admin')) |
name | string | No | Display name |
description | string | No | What this role can do |
#Auth Table
The user table is defined with defineTable like any other table. The platform manages the auth-mapped columns; everything else is yours.
import { db } from '@mindstudio-ai/agent'; export const Users = db.defineTable<{ // Mapped to auth — platform keeps these in sync email: string; phone?: string; roles: string[]; // Developer1free2pro3users');
#Platform-Managed Column Behavior
email/phone— read-only from code. Writing viapush(),update(), orupsert()throws aMindStudioError("Cannot write to email — this column is managed by auth. Use the auth API to change a user's email/phone."). The platform syncs these on auth events.roles— read/write from both code and the dashboard.Users.update(userId, { roles: ['admin'] })works and the platform syncs the change automatically. Dashboard role changes sync back to the table.- All other columns belong to the developer. Read, write, query as normal.
#Frontend Auth (Interface SDK)
The developer builds their own login/signup UI. The SDK provides methods that handle verification and session management. All auth state changes (verify, logout) update the SDK's internal config immediately — no page refresh needed.
import { auth } from '@mindstudio-ai/interface';
#User Shape
interface AppUser { id: string; email: string | null; phone: string | null; roles: string[]; provider?: 'remy' | null; // 1 = delegated sign-in (platform-managed); null/absent = app-verified createdAt: string; }
#State
auth.getCurrentUser() // AppUser | null (null = unauthenticated) auth.currentUser // AppUser | null (sync getter, same as getCurrentUser()) auth.isAuthenticated() // boolean auth.onAuthStateChanged(cb) // fires immediately with current user, then on every // auth transition (verify, confirm, logout). // Returns an unsubscribe function.
Use onAuthStateChanged in React instead of reading currentUser once at render time:
function useAuth() { const [user, setUser] = useState<AppUser | null>(null); useEffect(() => auth.onAuthStateChanged(setUser), []); return user; }
#Email Code Flow
const { verificationId } = await auth.sendEmailCode('user@example.com'); // Platform sends a 6-digit code to the email // User enters the code in your UI const user = await auth.verifyEmailCode(verificationId, '123456'); // Session is set — auth.getCurrentUser() now returns the AppUser
#SMS Code Flow
const { verificationId } = await auth.sendSmsCode('+15551234567'); // Phone must be E.164 format const user = await auth.verifySmsCode(verificationId, '123456');
#Sign in with Remy (delegated)
If the app is owned by an organization that has delegated sign-in enabled, add "remy" to auth.methods and offer a "Continue with {Org}" button. The platform resolves who the user is (like "Sign in with Google") and whether they're allowed; the app starts the flow and reads the result. When the organization requires delegated sign-in, remy is the only permitted human method (email/SMS are blocked at the platform edge for its apps).
// 0 button — must be triggered by a user gesture (click). <button onClick={() => auth.signInWithRemy()}>Continue with Acme</button> // Call once on app load — completes sign-in on return from the handshake, or // when the app is opened from the Remy dashboard. No-op when there1s safe on every mount. useEffect(() => { auth.handleRemyRedirect(); }, []); useEffect(() => auth.onAuthStateChanged(setUser), []);
auth.signInWithRemy(options?)→Promise<AppUser | null>. Top-level apps redirect to the platform and back. The page navigates away, so the promise never settles: drive UI offonAuthStateChanged, not the return value. Apps embedded in a cross-origin iframe (the dev IDE preview) use a popup and the promise resolves. Options:redirectUri(default current URL),state(CSRF, auto-generated),mode: 'auto' | 'popup' | 'redirect'(default'auto').auth.handleRemyRedirect()→Promise<AppUser | null>. Call once on load; handles both the button return and the dashboard-launch entry, updates the session in-place (firesonAuthStateChanged), and cleans the URL.- Delegated users have
provider: 'remy'; their roles and email are platform-managed — enforce withrequireRole/hasRole, but don't assign roles from app code.
#Email/Phone Changes (must be authenticated)
await auth.requestEmailChange('newemail@example.com'); // sends code to NEW email const user = await auth.confirmEmailChange('newemail@example.com', '123456'); await auth.requestPhoneChange('+15559876543'); const user = await auth.confirmPhoneChange('+15559876543', '123456');
#Logout
await auth.logout(); // clears cookie and session
#Error Codes
All auth methods throw on failure with a code property:
| Code | HTTP | Meaning |
|---|---|---|
rate_limited | 429 | Too many requests |
invalid_code | 400 | Wrong verification code |
verification_expired | 400 | Code has expired |
max_attempts_exceeded | 400 | Too many failed attempts |
not_authenticated | 401 | No active session |
invalid_session | 401 | Session expired or invalid |
auth_required | 401 | Agent/voice interface requires an authenticated user (interface auth block) |
role_required | 403 | Agent/voice interface requires a role the user doesn't hold |
#Phone Helpers (auth.phone)
Helpers for building phone input UIs with country code pickers:
auth.phone.countries // ~180 countries: { code, dialCode, name, flag } auth.phone.detectCountry() // guess from timezone, e.g. 0 auth.phone.toE164('5551234567', 'US') // 3 auth.phone.format('+15551234567') // 5 auth.phone.isValid('+15551234567') // true (E.164 format check)
#Email Helpers (auth.email)
auth.email.isValid('user@example.com') // true (basic format check)
#Backend Auth (Agent SDK)
import { auth } from '@mindstudio-ai/agent';
#auth.requireRole(...roles)
Throws 401 (unauthenticated) if there is no current user. Throws 403 (forbidden) if the current user doesn't have any of the specified roles. Use at the top of methods to gate access.
auth.requireRole('admin'); // single role auth.requireRole('admin', 'approver'); // any of these
#auth.hasRole(...roles)
Returns boolean. Same logic as requireRole but doesn't throw. Use for conditional behavior.
#auth.userId
The current user's ID (the row ID in the auth table), or null for unauthenticated requests.
#auth.roles
Array of role IDs assigned to the current user.
#auth.getUsersByRole(role)
Returns an array of user IDs with the specified role. Useful for "notify all admins."
#Login Page Example
import { useState, useEffect } from 'react'; import { auth } from '@mindstudio-ai/interface'; import { useLocation } from 'wouter'; function useAuth() { const [user, setUser] = useState<AppUser | null>(null); useEffect(() => auth.onAuthStateChanged(setUser), []); return user; } function LoginPage() { const user = useAuth(); const [, navigate] = useLocation(); const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [verificationId, setVerificationId] = useState(''); const [error, setError] = useState(''); // Redirect when authenticated (fires via onAuthStateChanged after verify) useEffect(() => { if (user) navigate('/dashboard'); }, [user]); const handleSendCode = async () => { try { const { verificationId } = await auth.sendEmailCode(email); setVerificationId(verificationId); setError(''); } catch (err: any) { setError(err.code === 'rate_limited' ? 'Too many attempts. Try again later.' : err.message); } }; const handleVerify = async () => { try { await auth.verifyEmailCode(verificationId, code); // onAuthStateChanged fires, useAuth updates, redirect happens } catch (err: any) { if (err.code === 'invalid_code') setError('Wrong code. Try again.'); else if (err.code === 'verification_expired') setError('Code expired. Request a new one.'); else if (err.code === 'max_attempts_exceeded') setError('Too many attempts. Request a new code.'); else setError(err.message); } }; if (!verificationId) { return ( <div> <h1>Sign in</h1> <input placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} /> <button onClick={handleSendCode}>Send code</button> {error && <p>{error}</p>} </div> ); } return ( <div> <p>Enter the code we sent to {email}</p> <input placeholder="123456" value={code} onChange={e => setCode(e.target.value)} /> <button onClick={handleVerify}>Verify</button> <button onClick={() => setVerificationId('')}>Resend</button> {error && <p>{error}</p>} </div> ); }
#Backend Method Example
import { auth } from '@mindstudio-ai/agent'; import { Users } from './tables/users'; export async function getDashboard() { const user = auth.userId ? await Users.get(auth.userId) : null; if (auth.hasRole('admin')) { const allUsers = await Users.toArray(); return { user, allUsers, isAdmin: true }; } return { user, isAdmin: false }; } export async function promoteToAdmin(input: { userId: string }) { auth.requireRole('admin'); await Users.update(input.userId, { roles: ['admin'] }); // SDK detects roles column write and syncs to platform automatically }
#Roles
Roles are platform-managed and stored on the developer's user table.
- Declared in manifest —
rolesarray withidandname - Stored as array — the mapped
rolescolumn holds["vendor", "admin"] - Writable from code —
Users.update(userId, { roles: ['admin'] })syncs automatically - Writable from dashboard — Remy dashboard shows app users and their roles
- Backend enforcement —
auth.requireRole('admin')reads from the platform's role cache
#Interface-Level Auth (Agent + Voice)
Agent and voice interfaces declare auth in their config as well: a required auth key, { "requireUser": boolean, "requireRole"?: string[] }. One of those sessions can spend money without ever calling a backend method, so the platform checks access before any model or media spend.
requireRoleuses the same manifest role ids with OR semantics, and requiresrequireUser: true.- Denials reach the frontend SDK as
MindStudioInterfaceErrorcodesauth_required(401) androle_required(403). - Dev preview is exempt. Older compiled apps without the block fall back to the manifest's
auth.enabled. - Method-level
auth.requireRole(...)checks still apply to every tool call inside the session.
See Interfaces for the full contract.
#Test User Roles (Dev Mode)
During development, test role-based behavior by assigning roles to the dev test user (remy@mindstudio.ai, the account the preview's sign-in helper auto-fills). This is a real write to the user's row, so auth.userId, requireRole, and role lookups behave exactly as in production:
POST /_internal/v2/apps/{appId}/dev/create-auth-session
Body: { "email": "remy@mindstudio.ai", "roles": ["ap"] }Sign in as the test account to see the app from that role's perspective. Roles persist on the row until changed; pass "roles": [] to remove them all.
Scenarios assign roles automatically: each scenario declares which roles the test user gets after seeding. See Scenarios.
#Apps Without Auth
Apps without auth in the manifest use anonymous guest sessions. No login, no user identity, no roles. This is the default and works fine for single-user apps, internal tools, and simple utilities. (Agent/voice interfaces on such apps declare "auth": { "requireUser": false } explicitly — anonymous callers are scoped by a per-browser visitor identity.)
#End-to-End Example
-
Add auth config to
mindstudio.json:json{ "auth": { "enabled": true, "methods": ["email-code"], "table": { "name": "users", "columns": { "email": "email", "roles": "roles" } } }, "roles": [{ "id": "admin", "name": "Admin" }] } -
Define the user table (
src/tables/users.ts):typescriptexport const Users = db.defineTable<{ email: string; roles: string[]; displayName: string; }>('users');
-
Build a login page using
auth.sendEmailCode()andauth.verifyEmailCode() -
Enforce roles in methods:
typescriptauth.requireRole('admin');
-
Conditional render in frontend:
typescriptconst { isAdmin } = await api.getUserContext(); {isAdmin && <DeleteButton />}
-
Test in dev by signing in as the test user, with roles set via scenarios or the Roles column