Skip to main content

User Operations API

Info

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.

Memory Follows the User

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 userId means 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" },
});
Note

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:

  1. GDPR Cascade - Deletes from the supported stores with userId across the supported primary scope
  2. Semantic clarity - users.get() is clearer than immutable.get('user', ...)
  3. Specialized validation - User-specific rules and constraints
  4. User-centric operations - Operations optimized for user management

Relationship to Layers:

User API Layer Relationships
Layer 1: ACID Stores

conversations., immutable., mutable.* — all support userId for GDPR cascade

Layer 2: Vector Index

vector.* — userId optional, included in GDPR cascade

Layer 3: Facts Store

facts.* — userId tracked, included in GDPR cascade

Convenience APIs

users.* wraps immutable (type='user') + supported administrative erasure

User Profiles vs Other Stores:

Featurecortex.users.*cortex.immutable.*cortex.mutable.*cortex.vector.*
Storageimmutable (type='user')immutablemutablevector index
SharedAll agentsAll agentsAll agentsPer-agent (memorySpace)
VersioningAuto (unlimited)Auto (20 versions)NoneAuto (10 versions)
userIdN/A (IS the user)OptionalOptionalOptional
GDPR Cascadethe supported storesNoNoNo
APIusers.get(id)immutable.get('user', id)mutable.get('users', id)vector.search(memorySpaceId, ...)
Use CaseUser profilesUser feedback, submissionsUser sessions, cacheMemory space memories

Key Differences:

  • ONLY cortex.users.* has GDPR cascade - erases the supported exclusive primary scope through explicit administration
  • cortex.users.* is a wrapper over cortex.immutable.* with type='user'
  • All stores (conversations, immutable, mutable, vector) support optional userId field
  • userId links 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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—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
}
Note

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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—Unique user identifier
dataRecord<string, unknown>Yes—User profile data to merge with existing (any JSON-serializable structure)
Note

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 updatedAt timestamp
  • 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 required
  • UserValidationError('INVALID_USER_ID_FORMAT') - User ID must be a non-empty string
  • UserValidationError('MISSING_DATA') - Data is required
  • UserValidationError('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 user profiles with filters, sorting, and pagination.

Signature:

cortex.users.search(
filters: UserFilters
): Promise<UserProfile[]>

Parameters:

ParameterTypeRequiredDefaultDescription
filters.limitnumberNo50Maximum results (max: 1000)
filters.offsetnumberNo0Skip first N results
filters.createdAfternumberNo—Filter by createdAt > timestamp (ms)
filters.createdBeforenumberNo—Filter by createdAt < timestamp (ms)
filters.updatedAfternumberNo—Filter by updatedAt > timestamp (ms)
filters.updatedBeforenumberNo—Filter by updatedAt < timestamp (ms)
filters.sortBy"createdAt" | "updatedAt"No"createdAt"Sort field
filters.sortOrder"asc" | "desc"No"desc"Sort direction
filters.displayNamestringNo—Filter by data.displayName (contains match)
filters.emailstringNo—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 object
  • UserValidationError('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:

ParameterTypeRequiredDefaultDescription
filters.limitnumberNo50Maximum results (max: 1000)
filters.offsetnumberNo0Skip first N results
filters.createdAfternumberNo—Filter by createdAt > timestamp (ms)
filters.createdBeforenumberNo—Filter by createdAt < timestamp (ms)
filters.updatedAfternumberNo—Filter by updatedAt > timestamp (ms)
filters.updatedBeforenumberNo—Filter by updatedAt < timestamp (ms)
filters.sortBy"createdAt" | "updatedAt"No"createdAt"Sort field
filters.sortOrder"asc" | "desc"No"desc"Sort direction
filters.displayNamestringNo—Filter by data.displayName (contains match)
filters.emailstringNo—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:

ParameterTypeRequiredDefaultDescription
filters.createdAfternumberNo—Count users created after timestamp (ms)
filters.createdBeforenumberNo—Count users created before timestamp (ms)
filters.updatedAfternumberNo—Count users updated after timestamp (ms)
filters.updatedBeforenumberNo—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:

ParameterTypeRequiredDefaultDescription
userIdsOrFiltersstring[] | UserFiltersYes—Array of user IDs (1-100) OR UserFilters object to select users
updates.dataRecord<string, unknown>Yes—Data to merge with existing profiles
options.skipVersioningbooleanNofalseDon't create versions
options.dryRunbooleanNofalsePreview 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 array
  • UserValidationError('USER_ID_ARRAY_TOO_LARGE') - More than 100 userIds
  • UserValidationError('TOO_MANY_MATCHES') - Filter matched >100 users
  • UserValidationError('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:

ParameterTypeRequiredDefaultDescription
options.format"json" | "csv"Yes—Output format
options.filtersUserFiltersNo—Filter users to export
options.includeVersionHistorybooleanNofalseInclude version history for each user
options.includeConversationsbooleanNofalseInclude user's conversations
options.includeMemoriesbooleanNofalseInclude user's memories (from conversation memory spaces)
Note

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 supported
  • CortexError('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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—User ID
versionnumberYes—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 data
  • null - 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 required
  • UserValidationError('INVALID_VERSION_NUMBER') - Version must be a number
  • UserValidationError('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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—User ID
timestampDateYes—Point in time to query

Returns:

  • UserVersion - Profile version that was current at that time
  • null - 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 required
  • UserValidationError('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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—User ID
defaultsRecord<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 required
  • UserValidationError('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:

ParameterTypeRequiredDefaultDescription
userIdstringYes—User ID
updatesRecord<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 required
  • UserValidationError('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}`);
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
}
Warning

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 limit if many are filtered out
  • For large datasets, consider using database-level filters (date ranges) first

Operations supporting filters:

  • search() - Returns UserProfile[]
  • list() - Returns ListUsersResult with pagination metadata
  • count() - Returns count (supports date filters only)
  • updateMany() - Filter-based bulk updates
  • deleteMany() - Filter-based bulk deletes
  • export() - 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,
},
});
}
Note

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,
});
Note

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`);
Note

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):

  • userId from Conversations (user's chat history)
  • userId from Contexts (user's workflows)
  • userId from Memories (user-specific knowledge)
  • userId from Facts (facts about the user)
  • userId from Immutable records (user feedback, submissions)
  • userId from Mutable records (user sessions, preferences)
  • userId in 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)​

ParameterTypeRequiredDefaultDescription
MISSING_USER_IDerrorNo—User ID is required (field: userId)
INVALID_USER_ID_FORMATerrorNo—User ID must be non-empty string (field: userId)
MISSING_DATAerrorNo—Data parameter is required (field: data)
INVALID_DATA_TYPEerrorNo—Data must be an object (not array) (field: data)
INVALID_VERSION_NUMBERerrorNo—Version must be a valid number (field: version)
INVALID_VERSION_RANGEerrorNo—Version must be >= 1 (field: version)
INVALID_TIMESTAMPerrorNo—Timestamp must be valid number >= 0 (field: timestamp)
INVALID_LIMITerrorNo—Limit must be 1-1000 (field: filters.limit)
INVALID_OFFSETerrorNo—Offset must be >= 0 (field: filters.offset)
INVALID_DATE_RANGEerrorNo—createdAfter must be < createdBefore (field: filters.*)
INVALID_SORT_BYerrorNo—sortBy must be createdAt/updatedAt (field: filters.sortBy)
INVALID_SORT_ORDERerrorNo—sortOrder must be asc/desc (field: filters.sortOrder)
INVALID_FILTER_STRUCTUREerrorNo—Filters must be an object (field: filters)
INVALID_DELETE_OPTIONSerrorNo—Options must be an object (field: options)
EMPTY_USER_IDS_ARRAYerrorNo—userIds array cannot be empty (field: userIds)
USER_ID_ARRAY_TOO_LARGEerrorNo—userIds array cannot exceed 100 (field: userIds)
TOO_MANY_MATCHESerrorNo—Filter matched >100 users (field: filters)
MISSING_REQUIRED_PARAMETERerrorNo—Export options are required (field: options)
INVALID_EXPORT_FORMATerrorNo—Format must be json/csv (field: format)

See Also:



Questions? Ask in GitHub Discussions.