User Operations API
Last Updated: 2026-01-08
Complete API reference for user profile management, including multi-tenancy and auth context integration.
Overview
The User Operations API (cortex.users.*) provides user profile management that enables memories to follow users across tools.
The primary purpose of user profiles is cross-tool memory sharing. Same userId = same memories, whether accessed from Cursor, Claude Desktop, custom apps, or any MCP-connected tool. This is the foundation of Hive Mode.
Key Features:
- Cross-Tool Memory Sharing - Same
userIdmeans same memories across all tools - GDPR Cascade Deletion - One call deletes across the supported primary scope with userId
- Multi-Tenancy - Full tenant isolation via
tenantId - Extensible Profiles - Flexible data field for any developer needs
- Version History - Time-travel queries for user data
Primary Feature: Cross-Tool Memory Sharing
// In Cursor: Store a preference
await cortex.memory.remember({
memorySpaceId: 'user-123-space',
userId: 'user-123', // Same userId
userMessage: 'I prefer TypeScript',
// ...
});
// In Claude Desktop: Recall the same memory
const memories = await cortex.memory.recall({
memorySpaceId: 'user-123-space',
userId: 'user-123', // Same userId = same memories
query: 'programming preferences',
});
// Returns: "User prefers TypeScript" - stored in Cursor, recalled in Claude!
Secondary feature: supported administrative erasure
Cascade requires the separate fixed administrative erasure transport and an exact tenant selection. Omitted tenant selects tenantless data only. Its scope is exclusive supported primary records, with shared/foreign/operational/provenance residuals disclosed in result.erasure; complete is supported verified completion, while fullUserDataErased remains false. Partial/unknown errors carry a receipt: inspect that and persisted state before another attempt. No transaction-wide rollback or universal physical erasure is promised. A configured graph or selected managed graph jobs additionally require an explicit graph participant using the matching adapter and fixed createGraphErasureTransport({url, deploymentKey}); manual/legacy graph data is preserved. Ordinary AuthContext is not administrator authority.
Profile-only deletion is separate. Use the explicit cascade example under delete(), not caller identity as maintenance authority. Complete legacy user export uses exportData, while users.export remains an ordinary scoped profile-oriented view. Export and erasure refuse scopes registered in runtimeAuthScopes, because new runtime tables require the corresponding runtime administration workflow.
Additional Feature: Semantic Convenience
Provides user-friendly syntax for user profile management:
// Convenience
await cortex.users.get("user-123");
// vs Equivalent
await cortex.immutable.get("user", "user-123");
Under the Hood
User profiles are stored in cortex.immutable.* with type='user':
// When you call:
await cortex.users.update("user-123", {
displayName: "Alex",
email: "alex@example.com",
});
// Cortex actually does:
await cortex.immutable.store({
type: "user",
id: "user-123",
data: { displayName: "Alex", email: "alex@example.com" },
});
The users.update() method accepts data fields directly (not wrapped in a data object). The SDK handles the conversion to the immutable store format internally.
Why the wrapper exists:
- GDPR Cascade - Deletes from the supported stores with
userIdacross the supported primary scope - Semantic clarity -
users.get()is clearer thanimmutable.get('user', ...) - Specialized validation - User-specific rules and constraints
- User-centric operations - Operations optimized for user management
Relationship to Layers:
conversations., immutable., mutable.* — all support userId for GDPR cascade
vector.* — userId optional, included in GDPR cascade
facts.* — userId tracked, included in GDPR cascade
users.* wraps immutable (type='user') + supported administrative erasure
User Profiles vs Other Stores:
| Feature | cortex.users.* | cortex.immutable.* | cortex.mutable.* | cortex.vector.* |
|---|---|---|---|---|
| Storage | immutable (type='user') | immutable | mutable | vector index |
| Shared | All agents | All agents | All agents | Per-agent (memorySpace) |
| Versioning | Auto (unlimited) | Auto (20 versions) | None | Auto (10 versions) |
| userId | N/A (IS the user) | Optional | Optional | Optional |
| GDPR Cascade | the supported stores | No | No | No |
| API | users.get(id) | immutable.get('user', id) | mutable.get('users', id) | vector.search(memorySpaceId, ...) |
| Use Case | User profiles | User feedback, submissions | User sessions, cache | Memory space memories |
Key Differences:
- ONLY
cortex.users.*has GDPR cascade - erases the supported exclusive primary scope through explicit administration cortex.users.*is a wrapper overcortex.immutable.*withtype='user'- All stores (conversations, immutable, mutable, vector) support optional
userIdfield userIdlinks enable GDPR cascade deletion
Multi-Tenancy Support
User profiles support full multi-tenant isolation via tenantId:
const erasureTransport = cortex.users.createErasureTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
// Create tenant-scoped user
await cortex.users.update("user-123", {
tenantId: "customer-acme", // SaaS platform isolation
data: {
displayName: "Alex",
email: "alex@acme.com",
preferences: { theme: "dark" },
},
});
// Query users within tenant
const users = await cortex.users.list({
tenantId: "customer-acme", // Only returns this tenant's users
limit: 100,
});
// Administrative erasure selects one exact tenant
await cortex.users.delete("user-123", {
cascade: true, erasureTransport, tenantId: "team-a",
// Deletes all user data across the supported stores with userId
// Graph nodes also include tenantId for proper isolation
});
Tenant Isolation in GDPR Cascade
When cascade deleting a user with tenantId, the supported exclusive primary selection includes exact tenant context:
// Cascade deletion covers:
// - User profile (with tenantId)
// - Conversations (all with userId, filtered by tenantId)
// - Immutable records (all with userId, filtered by tenantId)
// - Mutable keys (all with userId, filtered by tenantId)
// - Vector memories (all with userId, filtered by tenantId)
// - Facts (all with userId, filtered by tenantId)
// - Sessions (all with userId, filtered by tenantId)
// - Graph nodes (with userId AND tenantId properties)
Auth Context Integration
User operations integrate with the Auth Context system:
import { Cortex, createAuthContext } from "@cortexmemory/sdk";
// Create auth context from your auth provider
const auth = createAuthContext({
userId: "user-123",
tenantId: "customer-acme",
sessionId: "session-xyz",
claims: { email: "alex@acme.com" },
});
// Initialize Cortex with auth
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
auth,
});
// User operations use auth context
const profile = await cortex.users.get("user-123");
// tenantId is automatically validated from auth context
See Auth Integration for complete details.
Core Operations
get()
Retrieve a user profile by ID.
Signature:
cortex.users.get(
userId: string
): Promise<UserProfile | null>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | Unique user identifier |
Returns:
interface UserProfile {
// Identity
id: string; // User ID
tenantId?: string; // Multi-tenancy: tenant this user belongs to
// User Data (FLEXIBLE - structure is up to you!)
data: Record<string, unknown>; // Any JSON-serializable data
// System fields (automatic)
version: number; // Current version number
createdAt: number; // Unix timestamp (ms) when created
updatedAt: number; // Unix timestamp (ms) when last updated
}
Version history is accessed via getHistory() and getVersion() methods, not returned in the profile object.
Suggested Data Structure (not enforced):
// Common convention (but not required)
await cortex.users.update("user-123", {
data: {
displayName: "Alex Johnson", // Display name
email: "alex@example.com", // Contact email
// Preferences (your structure)
preferences: {
theme: "dark",
language: "en",
timezone: "America/New_York",
communicationStyle: "friendly",
},
// Metadata (your structure)
metadata: {
tier: "pro",
signupDate: new Date(),
company: "Acme Corp",
},
// Add ANY custom fields
customField1: "value",
customField2: { nested: "object" },
},
});
ONLY id is required - everything else is completely flexible!
Side Effects:
- None (read-only)
Example:
const user = await cortex.users.get("user-123");
if (user) {
console.log(`Welcome back, ${user.displayName}!`);
console.log(`Theme: ${user.preferences.theme || "default"}`);
console.log(`Member since: ${user.metadata.signupDate}`);
} else {
console.log("User not found - creating default profile");
}
Errors:
CortexError('INVALID_USER_ID')- User ID is empty or invalid
See Also:
update()
Create or update a user profile. This is an upsert operation with automatic versioning.
Signature:
cortex.users.update(
userId: string,
data: Record<string, unknown>
): Promise<UserProfile>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | Unique user identifier |
data | Record<string, unknown> | Yes | — | User profile data to merge with existing (any JSON-serializable structure) |
Unlike immutable.* which requires type and id, users.update() only needs userId (the type='user' is implicit). Updates are always merged with existing data by default (deep merge).
Returns:
UserProfile- Updated profile with incremented version
Side Effects:
- Creates new version in immutable store
- Updates
updatedAttimestamp - Deep merges with existing profile data automatically
Example 1: Create new profile
const user = await cortex.users.update("user-123", {
displayName: "Alex Johnson",
email: "alex@example.com",
preferences: {
theme: "dark",
language: "en",
timezone: "America/New_York",
},
tier: "free",
signupDate: new Date().toISOString(),
});
console.log(user.version); // 1 (first version)
console.log(user.data.displayName); // "Alex Johnson"
Example 2: Update existing profile (merge by default)
// Get current user
const user = await cortex.users.get("user-123");
// user.data = { displayName: 'Alex', email: 'alex@example.com', preferences: { theme: 'dark' } }
// Update theme (deep merges with existing, creates new version)
await cortex.users.update("user-123", {
preferences: {
theme: "light", // Only updates theme, keeps other preferences
},
});
// Result: { displayName: 'Alex', email: 'alex@example.com', preferences: { theme: 'light' } }
// Update last seen
await cortex.users.update("user-123", {
lastSeen: new Date().toISOString(),
});
Example 3: Multiple field updates
// Update multiple fields at once
await cortex.users.update("user-123", {
displayName: "Alex Johnson",
preferences: {
theme: "dark",
language: "es",
},
metadata: {
lastActivity: new Date().toISOString(),
},
});
Errors:
UserValidationError('MISSING_USER_ID')- User ID is requiredUserValidationError('INVALID_USER_ID_FORMAT')- User ID must be a non-empty stringUserValidationError('MISSING_DATA')- Data is requiredUserValidationError('INVALID_DATA_TYPE')- Data must be an object
See Also:
delete()
Delete a profile, or explicitly erase supported owned user data.
// Profile-only operation in the ordinary SDK scope.
await cortex.users.delete("user-123");
const erasureTransport = cortex.users.createErasureTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
try {
const preview = await cortex.users.delete("user-123", {
cascade: true,
tenantId: "team-a",
erasureTransport,
dryRun: true,
verify: true,
});
console.log(preview.erasure);
// Remove dryRun only after reviewing the exact selected domain.
} finally {
erasureTransport.close();
}
Cascade requires the separate fixed administrative erasure transport and an exact tenant selection. Omitted tenant selects tenantless data only. Its scope is exclusive supported primary records, with shared/foreign/operational/provenance residuals disclosed in result.erasure; complete is supported verified completion, while fullUserDataErased remains false. Partial/unknown errors carry a receipt: inspect that and persisted state before another attempt. No transaction-wide rollback or universal physical erasure is promised. A configured graph or selected managed graph jobs additionally require an explicit graph participant using the matching adapter and fixed createGraphErasureTransport({url, deploymentKey}); manual/legacy graph data is preserved. Ordinary AuthContext is not administrator authority.
cascade defaults false; verification defaults true. --no-verify/verify:false does not bypass preflight, ownership or readiness and does not certify verified completion. The receipt includes planned/outcome rows, per-table deleted/detached/preserved counts, remaining IDs, graph/verification states, errors, unknowns and explicit exclusions. Serialized document byte estimates are not physical storage freed.
search()
Search user profiles with filters, sorting, and pagination.
Signature:
cortex.users.search(
filters: UserFilters
): Promise<UserProfile[]>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filters.limit | number | No | 50 | Maximum results (max: 1000) |
filters.offset | number | No | 0 | Skip first N results |
filters.createdAfter | number | No | — | Filter by createdAt > timestamp (ms) |
filters.createdBefore | number | No | — | Filter by createdAt < timestamp (ms) |
filters.updatedAfter | number | No | — | Filter by updatedAt > timestamp (ms) |
filters.updatedBefore | number | No | — | Filter by updatedAt < timestamp (ms) |
filters.sortBy | "createdAt" | "updatedAt" | No | "createdAt" | Sort field |
filters.sortOrder | "asc" | "desc" | No | "desc" | Sort direction |
filters.displayName | string | No | — | Filter by data.displayName (contains match) |
filters.email | string | No | — | Filter by data.email (contains match) |
Returns:
UserProfile[]- Array of matching user profiles
Example 1: Basic search with limit
const users = await cortex.users.search({ limit: 10 });
for (const user of users) {
console.log(`${user.id}: ${user.data.displayName || "No name"}`);
}
Example 2: Search with date filters
// Find users created in the last 30 days
const recentUsers = await cortex.users.search({
createdAfter: Date.now() - 30 * 24 * 60 * 60 * 1000,
sortBy: "createdAt",
sortOrder: "desc",
});
Example 3: Search by displayName
// Find users with "alex" in their displayName
const alexUsers = await cortex.users.search({
displayName: "alex",
limit: 50,
});
Errors:
UserValidationError('INVALID_FILTER_STRUCTURE')- Filters must be an objectUserValidationError('INVALID_SORT_BY')- sortBy must be 'createdAt' or 'updatedAt'UserValidationError('INVALID_SORT_ORDER')- sortOrder must be 'asc' or 'desc'UserValidationError('INVALID_DATE_RANGE')- Date range is invalid
See Also:
list()
List user profiles with filtering, sorting, and pagination metadata.
Signature:
cortex.users.list(
filters?: ListUsersFilter
): Promise<ListUsersResult>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filters.limit | number | No | 50 | Maximum results (max: 1000) |
filters.offset | number | No | 0 | Skip first N results |
filters.createdAfter | number | No | — | Filter by createdAt > timestamp (ms) |
filters.createdBefore | number | No | — | Filter by createdAt < timestamp (ms) |
filters.updatedAfter | number | No | — | Filter by updatedAt > timestamp (ms) |
filters.updatedBefore | number | No | — | Filter by updatedAt < timestamp (ms) |
filters.sortBy | "createdAt" | "updatedAt" | No | "createdAt" | Sort field |
filters.sortOrder | "asc" | "desc" | No | "desc" | Sort direction |
filters.displayName | string | No | — | Filter by data.displayName (contains match) |
filters.email | string | No | — | Filter by data.email (contains match) |
Returns:
interface ListUsersResult {
users: UserProfile[]; // Array of user profiles
total: number; // Total count before pagination
limit: number; // Limit used for this query
offset: number; // Offset used for this query
hasMore: boolean; // Whether there are more results
}
Example 1: Basic list with pagination
// List users with pagination metadata
const result = await cortex.users.list({ limit: 50 });
console.log(`Retrieved ${result.users.length} of ${result.total} users`);
console.log(`Has more: ${result.hasMore}`);
for (const user of result.users) {
console.log(`- ${user.id}: ${user.data.displayName || "Unknown"}`);
}
Example 2: Paginate through all users
const page1 = await cortex.users.list({ limit: 10, offset: 0 });
const page2 = await cortex.users.list({ limit: 10, offset: 10 });
console.log(`Page 1: ${page1.users.length} users`);
console.log(`Page 2: ${page2.users.length} users`);
console.log(`Total: ${page1.total}`);
Example 3: List with date filters and sorting
// List recently updated users
const recent = await cortex.users.list({
updatedAfter: Date.now() - 7 * 24 * 60 * 60 * 1000, // Last week
sortBy: "updatedAt",
sortOrder: "desc",
limit: 20,
});
Errors:
CortexError('INVALID_PAGINATION')- Invalid limit/offset
See Also:
count()
Count user profiles with optional date filters.
Signature:
cortex.users.count(
filters?: UserFilters
): Promise<number>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
filters.createdAfter | number | No | — | Count users created after timestamp (ms) |
filters.createdBefore | number | No | — | Count users created before timestamp (ms) |
filters.updatedAfter | number | No | — | Count users updated after timestamp (ms) |
filters.updatedBefore | number | No | — | Count users updated before timestamp (ms) |
Returns:
number- Count of matching user profiles
Example 1: Total count
const total = await cortex.users.count();
console.log(`Total users: ${total}`);
Example 2: Count with date filters
// Count users created in the last 30 days
const recentSignups = await cortex.users.count({
createdAfter: Date.now() - 30 * 24 * 60 * 60 * 1000,
});
console.log(`New users (30 days): ${recentSignups}`);
// Count users active this week
const activeThisWeek = await cortex.users.count({
updatedAfter: Date.now() - 7 * 24 * 60 * 60 * 1000,
});
console.log(`Active users (7 days): ${activeThisWeek}`);
Errors:
UserValidationError('INVALID_DATE_RANGE')- Date range is invalid
See Also:
updateMany()
Bulk update multiple user profiles by explicit IDs or filters.
Signature:
// By explicit user IDs
cortex.users.updateMany(
userIds: string[],
updates: { data: Record<string, unknown> },
options?: UpdateManyOptions
): Promise<UpdateManyResult>
// By filters (select users matching criteria)
cortex.users.updateMany(
filters: UserFilters,
updates: { data: Record<string, unknown> },
options?: UpdateManyOptions
): Promise<UpdateManyResult>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userIdsOrFilters | string[] | UserFilters | Yes | — | Array of user IDs (1-100) OR UserFilters object to select users |
updates.data | Record<string, unknown> | Yes | — | Data to merge with existing profiles |
options.skipVersioning | boolean | No | false | Don't create versions |
options.dryRun | boolean | No | false | Preview without updating |
Returns:
interface UpdateManyResult {
updated: number; // Number of users successfully updated
userIds: string[]; // IDs of users that were updated
}
Example 1: Update by explicit IDs
const result = await cortex.users.updateMany(["user-1", "user-2", "user-3"], {
data: { status: "active", lastUpdatedBy: "admin" },
});
console.log(`Updated ${result.updated} users`);
Example 2: Update by filters
// Update all users created in the last 7 days
const result = await cortex.users.updateMany(
{ createdAfter: Date.now() - 7 * 24 * 60 * 60 * 1000 },
{ data: { welcomeEmailSent: true } },
);
console.log(`Sent welcome emails to ${result.updated} new users`);
// Update users by displayName
const alexResult = await cortex.users.updateMany(
{ displayName: "alex" },
{ data: { verified: true } },
);
Example 3: Dry run preview
const preview = await cortex.users.updateMany(
{ updatedBefore: Date.now() - 90 * 24 * 60 * 60 * 1000 },
{ data: { inactive: true } },
{ dryRun: true },
);
console.log(`Would update ${preview.userIds.length} inactive users`);
Errors:
UserValidationError('EMPTY_USER_IDS_ARRAY')- Empty userIds arrayUserValidationError('USER_ID_ARRAY_TOO_LARGE')- More than 100 userIdsUserValidationError('TOO_MANY_MATCHES')- Filter matched >100 usersUserValidationError('MISSING_DATA')- updates.data is required
deleteMany()
Select explicit user IDs or an ordinary scoped filter. Profile deletion is the default. Cascade uses one exact administrator transport/tenant and per-user receipts; a partially completed bulk operation is not atomic rollback.
const erasureTransport = cortex.users.createErasureTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
try {
const preview = await cortex.users.deleteMany(["user-1", "user-2"], {
cascade: true,
tenantId: "team-a",
erasureTransport,
dryRun: true,
verify: true,
});
console.log(preview);
} finally {
erasureTransport.close();
}
exportData()
Stream complete version1 data for the supported user closure:17 authoritative table families, eligible retained histories, shared membership projections and verified file bytes/metadata. Exact user/tenant linkage is validated; unresolved historical ownership is not guessed. This is deployment administration, separate from ordinary SDK caller scope.
const transport = cortex.users.createExportTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
for await (const event of cortex.users.exportData("user-123", {
transport,
tenantId: "team-a",
pageSize: 100,
})) {
const canonicalText = cortex.users.serializeExportEvent(event);
// Write to private staging; publish only after the terminal complete manifest.
}
Consume the stream through its terminal manifest; a partial iterator is not a complete export. Version1 is rolling-read, not an atomic deployment snapshot. Use the SDK serializer to preserve Convex encoding and canonical cross-language values. Blob metadata/digests are checked. External graph infrastructure and operational/reference/erasure records are disclosed exclusions. The SDK does not publish files; cortex users export performs private atomic JSON publication and leaves no final complete file on failure.
export()
Export user profiles to JSON or CSV format with optional related data.
Signature:
cortex.users.export(
options: ExportUsersOptions // Required - format is mandatory
): Promise<string>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
options.format | "json" | "csv" | Yes | — | Output format |
options.filters | UserFilters | No | — | Filter users to export |
options.includeVersionHistory | boolean | No | false | Include version history for each user |
options.includeConversations | boolean | No | false | Include user's conversations |
options.includeMemories | boolean | No | false | Include user's memories (from conversation memory spaces) |
The options parameter is required and must include format.
Returns:
string- Exported data as JSON or CSV string
Example 1: Basic export
// Export all users as JSON
const jsonExport = await cortex.users.export({ format: "json" });
console.log(jsonExport);
// Export with limit as CSV
const csvExport = await cortex.users.export({
format: "csv",
filters: { limit: 100 },
});
Example 2: Scoped profile view with related data
// Ordinary scoped view; use exportData for the complete supported user closure.
const gdprExport = await cortex.users.export({
format: "json",
filters: { displayName: "alex" },
includeVersionHistory: true,
includeConversations: true,
includeMemories: true,
});
// Save to file
import { writeFileSync } from "fs";
writeFileSync("exports/user-gdpr-data.json", gdprExport);
Example 3: Export with date filters
// Export recently created users
const recentExport = await cortex.users.export({
format: "json",
filters: {
createdAfter: Date.now() - 30 * 24 * 60 * 60 * 1000,
sortBy: "createdAt",
sortOrder: "desc",
},
});
CSV Format:
id,version,createdAt,updatedAt,data,versionHistoryCount,conversationsCount,memoriesCount
user-123,1,2025-01-01T00:00:00.000Z,2025-01-01T00:00:00.000Z,"{""displayName"":""Alex""}",3,5,12
Errors:
CortexError('INVALID_FORMAT')- Format not supportedCortexError('EXPORT_FAILED')- File write error
See Also:
Version Operations
getVersion()
Get a specific version of a user profile.
Signature:
cortex.users.getVersion(
userId: string,
version: number
): Promise<UserVersion | null>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID |
version | number | Yes | — | Version number to retrieve (must be >= 1) |
Returns:
interface UserVersion {
version: number; // Version number
data: Record<string, unknown>; // Profile data at this version
timestamp: number; // Unix timestamp (ms) when version was created
}
UserVersion- Specific version datanull- If version doesn't exist
Example:
// Get version 1 (original profile)
const v1 = await cortex.users.getVersion("user-123", 1);
if (v1) {
console.log(`Version: ${v1.version}`);
console.log(`Display name: ${v1.data.displayName}`);
console.log(`Theme: ${v1.data.preferences?.theme}`);
console.log(`Created: ${new Date(v1.timestamp).toISOString()}`);
}
Errors:
UserValidationError('MISSING_USER_ID')- User ID is requiredUserValidationError('INVALID_VERSION_NUMBER')- Version must be a numberUserValidationError('INVALID_VERSION_RANGE')- Version must be >= 1
getHistory()
Get all versions of a user profile, sorted by version descending (newest first).
Signature:
cortex.users.getHistory(
userId: string
): Promise<UserVersion[]>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID |
Returns:
UserVersion[]- Array of all profile versions (newest first)- Empty array
[]if user doesn't exist
interface UserVersion {
version: number; // Version number
data: Record<string, unknown>; // Profile data at this version
timestamp: number; // Unix timestamp (ms) when version was created
}
Example:
const history = await cortex.users.getHistory("user-123");
console.log(`User has ${history.length} profile versions:`);
history.forEach((v) => {
console.log(`v${v.version} (${new Date(v.timestamp).toISOString()}):`);
console.log(` Name: ${v.data.displayName}`);
console.log(` Theme: ${v.data.preferences?.theme}`);
});
// Analyze preference changes over time
const themeChanges = history.filter((v, i, arr) => {
return (
i > 0 && v.data.preferences?.theme !== arr[i - 1].data.preferences?.theme
);
});
console.log(`Theme changed ${themeChanges.length} times`);
Errors:
UserValidationError('MISSING_USER_ID')- User ID is required
See Also:
getAtTimestamp()
Get user profile state at a specific point in time.
Signature:
cortex.users.getAtTimestamp(
userId: string,
timestamp: Date
): Promise<UserVersion | null>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID |
timestamp | Date | Yes | — | Point in time to query |
Returns:
UserVersion- Profile version that was current at that timenull- If user didn't exist at that time
interface UserVersion {
version: number; // Version number
data: Record<string, unknown>; // Profile data at this version
timestamp: number; // Unix timestamp (ms) when version was created
}
Example:
// What was the user's theme on August 1st?
const augustProfile = await cortex.users.getAtTimestamp(
"user-123",
new Date("2025-08-01T00:00:00Z"),
);
if (augustProfile) {
console.log(`Theme in August: ${augustProfile.data.preferences?.theme}`);
console.log(`Tier in August: ${augustProfile.data.metadata?.tier}`);
}
// Track tier changes over time
const janProfile = await cortex.users.getAtTimestamp(
"user-123",
new Date("2025-01-01"),
);
const octProfile = await cortex.users.getAtTimestamp(
"user-123",
new Date("2025-10-01"),
);
const janTier = janProfile?.data.metadata?.tier;
const octTier = octProfile?.data.metadata?.tier;
if (janTier === "free" && octTier === "pro") {
console.log("User upgraded from free to pro this year!");
}
Errors:
UserValidationError('MISSING_USER_ID')- User ID is requiredUserValidationError('INVALID_TIMESTAMP')- Timestamp is invalid or negative
Advanced Operations
exists()
Check if a user profile exists without retrieving it.
Signature:
cortex.users.exists(
userId: string
): Promise<boolean>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID to check |
Example:
if (await cortex.users.exists("user-123")) {
console.log("Profile exists");
} else {
// Create default profile
await cortex.users.update("user-123", {
displayName: "New User",
preferences: {},
metadata: { signupDate: new Date() },
});
}
getOrCreate()
Get user profile or create with defaults if doesn't exist.
Signature:
cortex.users.getOrCreate(
userId: string,
defaults?: Record<string, unknown>
): Promise<UserProfile>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID |
defaults | Record<string, unknown> | No | — | Default profile data if user doesn't exist |
Returns:
UserProfile- Existing profile or newly created profile with defaults
Example:
// Get or create with defaults
const user = await cortex.users.getOrCreate("user-123", {
displayName: "Guest User",
preferences: {
theme: "light",
language: "en",
},
metadata: {
tier: "free",
},
});
// Use immediately (guaranteed to exist)
console.log(`Hello, ${user.data.displayName}!`);
Errors:
UserValidationError('MISSING_USER_ID')- User ID is requiredUserValidationError('INVALID_DATA_TYPE')- Defaults must be an object
merge()
Merge partial updates with existing profile. This is an alias for update() with merge behavior.
Signature:
cortex.users.merge(
userId: string,
updates: Record<string, unknown>
): Promise<UserProfile>
Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
userId | string | Yes | — | User ID |
updates | Record<string, unknown> | Yes | — | Partial updates to deep merge with existing data |
Returns:
UserProfile- Updated profile with merged data
Example:
// Existing profile
const existing = await cortex.users.get("user-123");
// existing.data = { displayName: 'Alex', preferences: { theme: 'dark', language: 'en' } }
// Merge update (deep merges, only changes specified fields)
await cortex.users.merge("user-123", {
preferences: {
notifications: true, // Adds notifications, keeps theme and language
},
});
// Result: { displayName: 'Alex', preferences: { theme: 'dark', language: 'en', notifications: true } }
Errors:
UserValidationError('MISSING_USER_ID')- User ID is requiredUserValidationError('MISSING_DATA')- Updates data is required
User-Memory Integration
Finding User's Memories
Use the memory.recall() orchestration API to get unified context for a user:
// Get memories and facts for a user
const result = await cortex.memory.recall({
memorySpaceId: "user-123-space",
query: "user preferences",
userId: "user-123",
limit: 20,
});
console.log(`Found ${result.items.length} relevant items`);
console.log(`Context for LLM: ${result.context}`);
Combining Profile + Memory Search
async function getCompleteUserContext(
userId: string,
memorySpaceId: string,
query: string,
) {
// Get profile
const profile = await cortex.users.get(userId);
// Get memories using recall
const memoryResult = await cortex.memory.recall({
memorySpaceId,
query,
userId,
limit: 10,
});
return {
profile,
memories: memoryResult.items,
context: memoryResult.context,
};
}
// Usage
const context = await getCompleteUserContext(
"user-123",
"user-123-space",
"user preferences",
);
console.log(`User: ${context.profile?.data.displayName}`);
console.log(`Found ${context.memories.length} relevant memories`);
Filters Reference
Filter options available for user operations:
interface UserFilters {
// Pagination
limit?: number; // Max results (default: 50, max: 1000)
offset?: number; // Skip first N results
// Date filters (Unix timestamps in milliseconds)
createdAfter?: number; // Filter by createdAt > timestamp
createdBefore?: number; // Filter by createdAt < timestamp
updatedAfter?: number; // Filter by updatedAt > timestamp
updatedBefore?: number; // Filter by updatedAt < timestamp
// Sorting
sortBy?: "createdAt" | "updatedAt"; // Sort field
sortOrder?: "asc" | "desc"; // Sort direction
// Client-side filters (see note below)
displayName?: string; // Filter by data.displayName (contains match)
email?: string; // Filter by data.email (contains match)
// Multi-tenancy
tenantId?: string; // Filter by tenant ID
}
Client-Side Filtering Note: The displayName and email filters are applied client-side after fetching results from the database. This means:
- Results are filtered AFTER pagination is applied
- You may receive fewer results than your
limitif many are filtered out - For large datasets, consider using database-level filters (date ranges) first
Operations supporting filters:
search()- ReturnsUserProfile[]list()- ReturnsListUsersResultwith pagination metadatacount()- Returns count (supports date filters only)updateMany()- Filter-based bulk updatesdeleteMany()- Filter-based bulk deletesexport()- Filter users to export
Example:
// Filter by date range
const recentUsers = await cortex.users.list({
createdAfter: Date.now() - 30 * 24 * 60 * 60 * 1000, // Last 30 days
sortBy: "createdAt",
sortOrder: "desc",
limit: 50,
});
// Client-side displayName filter
const alexUsers = await cortex.users.search({
displayName: "alex", // Case-insensitive contains match
limit: 100,
});
// Bulk update with filters
await cortex.users.updateMany(
{ createdAfter: Date.now() - 7 * 24 * 60 * 60 * 1000 },
{ data: { welcomeEmailSent: true } },
);
🚧 Planned: Advanced Filters (Not Yet Implemented)
The following advanced filter capabilities are planned for a future release:
// 🚧 PLANNED - NOT YET IMPLEMENTED:
interface AdvancedFilters {
// Nested object filters (planned)
"preferences.theme"?: "light" | "dark";
"metadata.tier"?: "free" | "pro" | "enterprise";
// Range query operators (planned)
// { $gte, $lte, $eq, $ne, $gt, $lt }
}
Real-World Patterns
Pattern 1: Initialize on First Contact
async function ensureUserProfile(userId: string, message: string) {
// Get or create
let user = await cortex.users.get(userId);
if (!user) {
user = await cortex.users.update(userId, {
displayName: userId, // Temporary
preferences: {
language: detectLanguage(message),
timezone: "UTC",
},
metadata: {
tier: "free",
signupDate: new Date(),
firstMessage: message,
},
});
}
return user;
}
Pattern 2: Progressive Enhancement
Build profiles over time from conversations:
async function enhanceProfileFromConversation(
userId: string,
conversation: string,
) {
const user = await cortex.users.get(userId);
const existingData = user?.data || {};
// Extract insights from conversation
const insights = await extractUserInsights(conversation);
// Update profile incrementally (automatically deep merges)
await cortex.users.update(userId, {
displayName: insights.name || existingData.displayName,
email: insights.email || existingData.email,
preferences: {
...(existingData.preferences || {}),
...insights.preferences, // Add new preferences
},
metadata: {
...(existingData.metadata || {}),
lastSeen: new Date().toISOString(),
conversationCount:
((existingData.metadata?.conversationCount as number) || 0) + 1,
},
});
}
Each update() call creates a new version automatically. For use cases requiring version control (like skip versioning for routine updates), this feature is planned for a future release.
Pattern 3: Personalized Responses
Adapt agent behavior to user preferences:
async function generatePersonalizedResponse(userId: string, message: string) {
const user = await cortex.users.get(userId);
// Generate response
let response = await yourLLM.generate(message);
// Apply communication style
if (user.preferences.communicationStyle === "formal") {
response = makeFormal(response);
} else if (user.preferences.communicationStyle === "casual") {
response = makeCasual(response);
}
// Translate if needed
if (user.preferences.language && user.preferences.language !== "en") {
response = await translate(response, user.preferences.language);
}
// Format for timezone
if (user.preferences.timezone) {
response = formatDatesForTimezone(response, user.preferences.timezone);
}
return response;
}
Pattern 4: Multi-Tenant User Management
Use auth context for tenant isolation:
import { Cortex, createAuthContext } from "@cortexmemory/sdk";
// Initialize Cortex with tenant-scoped auth
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
auth: createAuthContext({
tenantId: "tenant-abc", // All operations scoped to this tenant
userId: "admin-user",
}),
});
// Create user (automatically tagged with tenantId)
await cortex.users.update("user-123", {
displayName: "Alex Johnson",
role: "admin",
permissions: ["read", "write", "admin"],
});
// List users (automatically filtered by tenant from auth context)
const result = await cortex.users.list({ limit: 100 });
console.log(`Tenant has ${result.total} users`);
// Count users in tenant
const count = await cortex.users.count();
// Export tenant data (GDPR)
const tenantData = await cortex.users.export({
format: "json",
includeMemories: true,
includeConversations: true,
});
Pattern 5: Preference Sync UI
Sync user preferences from frontend:
// User changes preferences in your app
async function handlePreferenceChange(
userId: string,
section: string,
value: unknown,
) {
// Update just that preference section (deep merges automatically)
await cortex.users.update(userId, {
preferences: {
[section]: value,
},
});
// All agents immediately see the change
console.log("Preference updated across all agents");
}
// Example usage
await handlePreferenceChange("user-123", "theme", "dark");
await handlePreferenceChange("user-123", "notifications", {
email: true,
push: false,
});
Pattern 6: User Analytics
Track user engagement and behavior:
async function updateUserEngagement(userId: string, event: string) {
const user = await cortex.users.get(userId);
const existingMetadata = (user?.data.metadata || {}) as Record<
string,
unknown
>;
await cortex.users.update(userId, {
metadata: {
...existingMetadata,
lastSeen: new Date().toISOString(),
totalSessions: ((existingMetadata.totalSessions as number) || 0) + 1,
lastAction: event,
engagementScore: calculateEngagement(user),
},
});
}
function calculateEngagement(
user: { data: Record<string, unknown> } | null,
): number {
if (!user) return 0;
const metadata = (user.data.metadata as Record<string, unknown>) || {};
const signupDate = metadata.signupDate as string;
if (!signupDate) return 0;
const daysSinceSignup =
(Date.now() - new Date(signupDate).getTime()) / (24 * 60 * 60 * 1000);
const sessions = (metadata.totalSessions as number) || 0;
return Math.min((sessions / daysSinceSignup) * 10, 100);
}
Multi-Tenant Patterns
Tenant-Specific User Management
The SDK supports multi-tenancy through the tenantId field in auth context:
import { Cortex, createAuthContext } from "@cortexmemory/sdk";
// Initialize with tenant-specific auth context
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL!,
auth: createAuthContext({
tenantId: "tenant-acme",
userId: "admin-user",
}),
});
// All operations are now scoped to tenant-acme
// Users can only see/modify data within their tenant
// Create user in tenant
await cortex.users.update("alex", {
displayName: "Alex Johnson",
role: "admin",
});
// List users (automatically filtered by tenantId from auth context)
const result = await cortex.users.list({ limit: 100 });
console.log(`Tenant has ${result.total} users`);
// Export tenant users
const exportData = await cortex.users.export({
format: "json",
includeConversations: true,
includeMemories: true,
});
When using authContext, the tenantId is automatically injected into all operations. Users can only access data within their tenant.
Configuration
🚧 Planned: Profile Validation (Not Yet Implemented)
The following configuration options are planned for a future release:
// 🚧 PLANNED - NOT YET IMPLEMENTED
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL,
// userProfileValidation: {
// requiredFields: ["displayName"],
// maxDisplayNameLength: 100,
// emailRequired: false,
// validateEmail: true,
// },
});
Currently, profile validation is handled through the SDK's built-in validators which check:
- User ID is a non-empty string
- Data is a valid object (not array, not null)
🚧 Planned: Version Retention (Not Yet Implemented)
Version retention configuration is planned for a future release:
// 🚧 PLANNED - NOT YET IMPLEMENTED
const cortex = new Cortex({
convexUrl: process.env.CONVEX_URL,
// userProfileVersionRetention: 20, // Keep last 20 versions
});
Currently, user profiles keep all versions indefinitely (no automatic cleanup). Version history can be retrieved via getHistory() and getVersion() methods.
Best Practices
1. Minimal Required Fields
Only require essential information:
// Start minimal
await cortex.users.update(userId, {
displayName: name,
});
// Add more as you learn
await cortex.users.update(userId, {
preferences: { theme: "dark" },
});
2. Use Defaults
Provide sensible defaults:
const defaultPreferences = {
theme: "light",
language: "en",
timezone: "UTC",
notifications: true,
};
await cortex.users.update(userId, {
displayName: name,
preferences: { ...defaultPreferences, ...customPreferences },
});
3. Version Management Best Practices
Currently, every update() call creates a new version. Here are tips for managing version history:
// Important changes - update normally (creates version)
await cortex.users.update(userId, {
preferences: { emailNotifications: false }, // User opt-out
});
// Routine updates - still creates versions but that's OK
// Use getHistory() to review version history when needed
await cortex.users.update(userId, {
metadata: {
lastSeen: new Date().toISOString(),
sessionCount: ((user?.data.metadata?.sessionCount as number) || 0) + 1,
},
});
// Tip: Review version history periodically
const history = await cortex.users.getHistory(userId);
console.log(`User has ${history.length} profile versions`);
Skip versioning option is planned for a future release to optimize routine updates.
4. Privacy-First
Don't store unnecessary PII:
// Avoid: Don't store sensitive data
await cortex.users.update(userId, {
metadata: {
ssn: "123-45-6789", // Never!
creditCard: "4111...", // Never!
},
});
// Good: Store references only
await cortex.users.update(userId, {
metadata: {
paymentMethodId: "pm_abc123", // Stripe reference
hasPaymentMethod: true,
},
});
5. Use TypeScript for Type Safety
Define custom interfaces for your profile data structure:
// Define your custom data shape
interface MyUserData {
displayName: string;
email?: string;
preferences: {
theme: "light" | "dark";
language: "en" | "es" | "fr";
emailFrequency: "daily" | "weekly" | "never";
};
metadata: {
tier: "free" | "pro" | "enterprise";
credits: number;
referralCode?: string;
};
}
// Type-safe wrapper function
async function getTypedUser(userId: string): Promise<MyUserData | null> {
const user = await cortex.users.get(userId);
if (!user) return null;
return user.data as MyUserData;
}
// Usage with type safety
const userData = await getTypedUser(userId);
if (userData) {
console.log(userData.preferences.emailFrequency); // Typed!
}
6. Review export and erasure scope
Use users.exportData for complete supported-layer administrative user export and private staging, then independently review the exact erasure preview. Export and erasure are different fixed capabilities; neither is granted by a user ID or JWT. Retain the returned erasure receipt and its residual/partial/unknown qualifications rather than labeling the operation universal physical deletion.
Graph-Lite Capabilities
Users are universal graph nodes - everything in Cortex can link to a user via the userId field:
User as Graph Hub:
- Central node connecting all user-related data across the system
- Enables GDPR cascade deletion across the supported primary scope
Incoming Edges (What Links To Users):
userIdfrom Conversations (user's chat history)userIdfrom Contexts (user's workflows)userIdfrom Memories (user-specific knowledge)userIdfrom Facts (facts about the user)userIdfrom Immutable records (user feedback, submissions)userIdfrom Mutable records (user sessions, preferences)userIdin Graph nodes (if graph adapter configured)
GDPR Cascade as Graph Operation:
With an explicit administrator transport, cascade evaluates supported physical ownership and exact tenant linkage; a matching userId alone does not authorize deletion. Shared records are detached/preserved and graph work requires the matching participant:
User-123 (delete this node)
↓ supported exclusive physical ownership and exact tenant scope
├──> Conversations with userId='user-123' (delete)
├──> Memories with userId='user-123' (delete)
├──> Facts with userId='user-123' (delete)
├──> Immutable records with userId='user-123' (delete)
├──> Mutable records with userId='user-123' (delete)
└──> Managed graph projection (explicit participant; legacy/manual data preserved)
Example Result:
const erasureTransport = cortex.users.createErasureTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
const result = await cortex.users.delete("user-123", { cascade: true, erasureTransport, tenantId: "team-a" });
// Result shows per-layer deletion counts:
// {
// conversationsDeleted: 15,
// vectorMemoriesDeleted: 234,
// factsDeleted: 89,
// immutableRecordsDeleted: 5,
// mutableKeysDeleted: 12,
// graphNodesDeleted: 47,
// totalDeleted: 403,
// deletedLayers: ['conversations', 'vector', 'facts', 'immutable', 'mutable', 'graph', 'user-profile']
// }
Learn more: Graph Capabilities
Error Reference
The public SDK exports UserValidationError. Cascade failures use an error named CascadeDeletionError with a cause and optional erasure receipt; that class is not a named export of the public SDK entrypoint.
UserValidationError
Thrown when client-side validation fails (before reaching the backend):
import { UserValidationError } from "@cortexmemory/sdk";
try {
await cortex.users.update("", { name: "Test" }); // Empty userId
} catch (error) {
if (error instanceof UserValidationError) {
console.log(error.code); // "MISSING_USER_ID"
console.log(error.field); // "userId"
console.log(error.message); // "userId is required"
}
}
CascadeDeletionError
Thrown when supported cascade erasure stops. Earlier committed deletions remain committed; a failed or unknown later outcome does not restore them. Inspect the optional receipt before any further mutation. Bulk failures likewise retain earlier per-user outcomes and unstarted users.
const erasureTransport = cortex.users.createErasureTransport({
url: process.env.CONVEX_URL!,
deploymentKey: process.env.CONVEX_DEPLOY_KEY!,
});
try {
await cortex.users.delete("user-123", { cascade: true, erasureTransport, tenantId: "team-a" });
} catch (error) {
if (error instanceof Error && error.name === "CascadeDeletionError") {
console.error(error.message);
console.error(error.cause); // Original failure; this does not imply rollback
if ("receipt" in error) {
console.log(error.receipt); // Recorded outcomes, remaining IDs and unknowns
}
// Inspect recorded and persisted state before deciding how to continue.
// Do not automatically replay delete(); another call creates a new operation.
}
}
Receipt inspection is read-only. The fixed transport's getReceiptPage observes the original operation using its exact domain, operation ID and item key; it does not resume or replay eraseRecord. An optional error receipt describes the supported scope, committed outcomes, remaining IDs, unknowns and residuals. Resolve unknown outcomes against the original persisted receipts before considering another mutation. No transaction-wide rollback, automatic restoration or public user-erasure resume method is promised.
Validation Error Codes (UserValidationError)
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
MISSING_USER_ID | error | No | — | User ID is required (field: userId) |
INVALID_USER_ID_FORMAT | error | No | — | User ID must be non-empty string (field: userId) |
MISSING_DATA | error | No | — | Data parameter is required (field: data) |
INVALID_DATA_TYPE | error | No | — | Data must be an object (not array) (field: data) |
INVALID_VERSION_NUMBER | error | No | — | Version must be a valid number (field: version) |
INVALID_VERSION_RANGE | error | No | — | Version must be >= 1 (field: version) |
INVALID_TIMESTAMP | error | No | — | Timestamp must be valid number >= 0 (field: timestamp) |
INVALID_LIMIT | error | No | — | Limit must be 1-1000 (field: filters.limit) |
INVALID_OFFSET | error | No | — | Offset must be >= 0 (field: filters.offset) |
INVALID_DATE_RANGE | error | No | — | createdAfter must be < createdBefore (field: filters.*) |
INVALID_SORT_BY | error | No | — | sortBy must be createdAt/updatedAt (field: filters.sortBy) |
INVALID_SORT_ORDER | error | No | — | sortOrder must be asc/desc (field: filters.sortOrder) |
INVALID_FILTER_STRUCTURE | error | No | — | Filters must be an object (field: filters) |
INVALID_DELETE_OPTIONS | error | No | — | Options must be an object (field: options) |
EMPTY_USER_IDS_ARRAY | error | No | — | userIds array cannot be empty (field: userIds) |
USER_ID_ARRAY_TOO_LARGE | error | No | — | userIds array cannot exceed 100 (field: userIds) |
TOO_MANY_MATCHES | error | No | — | Filter matched >100 users (field: filters) |
MISSING_REQUIRED_PARAMETER | error | No | — | Export options are required (field: options) |
INVALID_EXPORT_FORMAT | error | No | — | Format must be json/csv (field: format) |
See Also:
Questions? Ask in GitHub Discussions.