From 623e33f2dbcb8c469dacc2c9b7fbebdf3d1484f5 Mon Sep 17 00:00:00 2001
From: Pratik <10096516+pratikgajjar@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:04:09 -0700
Subject: [PATCH] feat(profiles): SDK methods and docs for entity profiles
Adds the profile surface to both SDKs and documents it.
Python (MemoryClient + AsyncMemoryClient) and TypeScript gain:
get/generate profile, get/update settings, sample, regenerate.
A profile's keys come from the customer's own JSON Schema, so the TS
client keeps `profile` opaque and passes `schema` through untouched in
both directions. Camel-casing them would return field names that do not
match the schema the customer wrote.
Reads use the v2 envelope, where a known entity with no profile yet is a
200 carrying a status rather than a 404, so an empty state is
distinguishable from an error.
Verified against a live environment running this API: settings
round-trip, a partial update leaves the schema intact, generation
produces a profile under the configured schema, and sample, regenerate,
cooldown and the 400/404 paths all answer as documented.
---
.../profiles/get-profile-settings.mdx | 5 +
docs/api-reference/profiles/get-profile.mdx | 5 +
.../profiles/regenerate-profiles.mdx | 5 +
.../profiles/sample-profiles.mdx | 5 +
.../profiles/trigger-profile.mdx | 5 +
.../profiles/update-profile-settings.mdx | 5 +
docs/docs.json | 13 +
docs/llms.txt | 7 +
docs/openapi.json | 373 +++++++++++++++++-
docs/platform/features/user-profiles.mdx | 272 +++++++++++++
mem0-ts/src/client/index.ts | 8 +
mem0-ts/src/client/mem0.ts | 152 ++++++-
mem0-ts/src/client/mem0.types.ts | 54 +++
.../tests/memoryClient.profiles.test.ts | 238 +++++++++++
mem0-ts/src/client/utils.ts | 3 +
mem0/client/main.py | 298 ++++++++++++++
tests/test_client_profiles.py | 224 +++++++++++
17 files changed, 1668 insertions(+), 4 deletions(-)
create mode 100644 docs/api-reference/profiles/get-profile-settings.mdx
create mode 100644 docs/api-reference/profiles/get-profile.mdx
create mode 100644 docs/api-reference/profiles/regenerate-profiles.mdx
create mode 100644 docs/api-reference/profiles/sample-profiles.mdx
create mode 100644 docs/api-reference/profiles/trigger-profile.mdx
create mode 100644 docs/api-reference/profiles/update-profile-settings.mdx
create mode 100644 docs/platform/features/user-profiles.mdx
create mode 100644 mem0-ts/src/client/tests/memoryClient.profiles.test.ts
create mode 100644 tests/test_client_profiles.py
diff --git a/docs/api-reference/profiles/get-profile-settings.mdx b/docs/api-reference/profiles/get-profile-settings.mdx
new file mode 100644
index 000000000..82eb1b37b
--- /dev/null
+++ b/docs/api-reference/profiles/get-profile-settings.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Get Profile Settings'
+description: "Retrieve the profile schema, custom instructions, and enabled flag for the current project."
+openapi: get /v2/profiles/settings/
+---
diff --git a/docs/api-reference/profiles/get-profile.mdx b/docs/api-reference/profiles/get-profile.mdx
new file mode 100644
index 000000000..fc10ef8f4
--- /dev/null
+++ b/docs/api-reference/profiles/get-profile.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Get Profile'
+description: "Retrieve the structured profile for a user or agent, with a status describing whether generation has completed."
+openapi: get /v2/entities/{entity_type}/{entity_id}/profile/
+---
diff --git a/docs/api-reference/profiles/regenerate-profiles.mdx b/docs/api-reference/profiles/regenerate-profiles.mdx
new file mode 100644
index 000000000..694e895fb
--- /dev/null
+++ b/docs/api-reference/profiles/regenerate-profiles.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Regenerate Profiles'
+description: "Rebuild the profile of every entity in the project, applying the current schema to entities that already have one."
+openapi: post /v2/profiles/regenerate/
+---
diff --git a/docs/api-reference/profiles/sample-profiles.mdx b/docs/api-reference/profiles/sample-profiles.mdx
new file mode 100644
index 000000000..ff231a586
--- /dev/null
+++ b/docs/api-reference/profiles/sample-profiles.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Sample Profiles'
+description: "Generate profiles for a small set of real entities to evaluate a schema before applying it across the project."
+openapi: post /v2/profiles/samples/
+---
diff --git a/docs/api-reference/profiles/trigger-profile.mdx b/docs/api-reference/profiles/trigger-profile.mdx
new file mode 100644
index 000000000..4dfde7709
--- /dev/null
+++ b/docs/api-reference/profiles/trigger-profile.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Generate Profile'
+description: "Generate or refresh the profile for a single user or agent immediately, instead of waiting for the message threshold."
+openapi: post /v2/profiles/trigger/
+---
diff --git a/docs/api-reference/profiles/update-profile-settings.mdx b/docs/api-reference/profiles/update-profile-settings.mdx
new file mode 100644
index 000000000..e33dc9198
--- /dev/null
+++ b/docs/api-reference/profiles/update-profile-settings.mdx
@@ -0,0 +1,5 @@
+---
+title: 'Update Profile Settings'
+description: "Set the JSON Schema, custom instructions, or enabled flag that control profile generation for the project."
+openapi: post /v2/profiles/settings/
+---
diff --git a/docs/docs.json b/docs/docs.json
index c1c4887d2..ed22f8635 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -71,6 +71,7 @@
"pages": [
"platform/features/v2-memory-filters",
"platform/features/entity-scoped-memory",
+ "platform/features/user-profiles",
"platform/features/graph-memory",
"platform/features/async-client",
"platform/features/multimodal-support",
@@ -511,6 +512,18 @@
"api-reference/entities/delete-user"
]
},
+ {
+ "group": "Profiles",
+ "icon": "id-card",
+ "pages": [
+ "api-reference/profiles/get-profile",
+ "api-reference/profiles/trigger-profile",
+ "api-reference/profiles/get-profile-settings",
+ "api-reference/profiles/update-profile-settings",
+ "api-reference/profiles/sample-profiles",
+ "api-reference/profiles/regenerate-profiles"
+ ]
+ },
{
"group": "Organizations",
"icon": "building",
diff --git a/docs/llms.txt b/docs/llms.txt
index 65e13de8f..dfedf13b9 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -197,6 +197,7 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
### Features - Essential
- [V2 Memory Filters](https://docs.mem0.ai/platform/features/v2-memory-filters) [Platform]: Use when compound filters (AND/OR on metadata, entity, time) are needed at search.
- [Entity-Scoped Memory](https://docs.mem0.ai/platform/features/entity-scoped-memory) [Platform]: Use when partitioning memories by user, agent, app, or run.
+- [Profiles](https://docs.mem0.ai/platform/features/user-profiles) [Platform]: Use when a structured always-current summary of a user or agent is needed in one read, instead of searching their memories.
- [Graph Memory](https://docs.mem0.ai/platform/features/graph-memory) [Platform]: Use when connecting facts across memories through shared entities for entity-centric or multi-hop questions.
- [Async Client](https://docs.mem0.ai/platform/features/async-client) [Platform]: Use when the app issues many concurrent Mem0 calls and needs non-blocking I/O.
- [Multimodal Support](https://docs.mem0.ai/platform/features/multimodal-support) [Platform]: Use when storing images or PDFs as memory input.
@@ -362,6 +363,12 @@ All API Reference docs describe Mem0 Platform REST endpoints (requires API key).
### Entities
- [Get Users](https://docs.mem0.ai/api-reference/entities/get-users) [Platform]: Use when listing users, agents, or apps known to a project.
- [Delete User](https://docs.mem0.ai/api-reference/entities/delete-user) [Platform]: Use when removing an entity and all its memories.
+- [Get Profile](https://docs.mem0.ai/api-reference/profiles/get-profile) [Platform]: Use when reading a user's or agent's structured profile and branching on its generation status.
+- [Generate Profile](https://docs.mem0.ai/api-reference/profiles/trigger-profile) [Platform]: Use when a profile is needed before the entity reaches the automatic message threshold.
+- [Get Profile Settings](https://docs.mem0.ai/api-reference/profiles/get-profile-settings) [Platform]: Use when checking the project's profile schema, instructions, or enabled flag.
+- [Update Profile Settings](https://docs.mem0.ai/api-reference/profiles/update-profile-settings) [Platform]: Use when defining or changing the JSON Schema that shapes profiles for a project.
+- [Sample Profiles](https://docs.mem0.ai/api-reference/profiles/sample-profiles) [Platform]: Use when validating a profile schema against a few real entities before applying it project-wide.
+- [Regenerate Profiles](https://docs.mem0.ai/api-reference/profiles/regenerate-profiles) [Platform]: Use when a new schema must be applied to entities that already have a profile.
### Organizations
- [Create Organization](https://docs.mem0.ai/api-reference/organization/create-org) [Platform]: Use when setting up a new org.
diff --git a/docs/openapi.json b/docs/openapi.json
index af215596f..cef11fed4 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -8070,6 +8070,377 @@
}
}
}
+ },
+ "/v2/entities/{entity_type}/{entity_id}/profile/": {
+ "get": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_read",
+ "summary": "Get an entity's profile",
+ "description": "Return the memory profile for one user or agent.\n\nGeneration is asynchronous, so a known entity that has no profile yet is a normal 200 carrying a `status`. A 404 means only that no such entity exists.",
+ "parameters": [
+ {
+ "name": "entity_type",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "user",
+ "agent"
+ ]
+ },
+ "description": "The kind of entity that carries the profile."
+ },
+ {
+ "name": "entity_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The entity's id, as supplied when the memory was added."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The profile envelope.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "profile": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "The generated profile, shaped by the project's schema. Empty unless status is succeeded."
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "succeeded",
+ "pending",
+ "failed",
+ "not_enabled",
+ "insufficient_data"
+ ],
+ "description": "Generation state. Branch on this rather than on an empty profile."
+ },
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "user",
+ "agent"
+ ]
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "generation_count": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Unsupported entity type."
+ },
+ "404": {
+ "description": "No such entity in this project."
+ }
+ }
+ }
+ },
+ "/v2/profiles/settings/": {
+ "get": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_settings_read",
+ "summary": "Get profile settings",
+ "description": "Return the profile settings for the project the API key is scoped to.",
+ "responses": {
+ "200": {
+ "description": "Current settings.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether profile generation runs for this project."
+ },
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "nullable": true,
+ "description": "JSON Schema describing the profile. Every property needs a description."
+ },
+ "custom_instructions": {
+ "type": "string",
+ "nullable": true,
+ "description": "Extra guidance for the extraction step."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_settings_update",
+ "summary": "Update profile settings",
+ "description": "Update the project's profile settings. Only the fields present in the body are written, so one setting can change without re-sending the others.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether profile generation runs for this project."
+ },
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "nullable": true,
+ "description": "JSON Schema describing the profile. Every property needs a description."
+ },
+ "custom_instructions": {
+ "type": "string",
+ "nullable": true,
+ "description": "Extra guidance for the extraction step."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Settings as stored after the update.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether profile generation runs for this project."
+ },
+ "schema": {
+ "type": "object",
+ "additionalProperties": true,
+ "nullable": true,
+ "description": "JSON Schema describing the profile. Every property needs a description."
+ },
+ "custom_instructions": {
+ "type": "string",
+ "nullable": true,
+ "description": "Extra guidance for the extraction step."
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "The schema is not a valid profile schema."
+ }
+ }
+ }
+ },
+ "/v2/profiles/trigger/": {
+ "post": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_trigger",
+ "summary": "Generate one entity's profile now",
+ "description": "Generate or refresh the profile for a single entity immediately.\n\nProfiles are otherwise built once an entity crosses an internal message threshold, so a new entity has none for its first few memories. Returns 202: poll the read endpoint and branch on `status`.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "entity_id"
+ ],
+ "properties": {
+ "entity_type": {
+ "type": "string",
+ "enum": [
+ "user",
+ "agent"
+ ],
+ "default": "user"
+ },
+ "entity_id": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Generation queued.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "entity_type": {
+ "type": "string"
+ },
+ "entity_id": {
+ "type": "string"
+ },
+ "profile_id": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Unsupported entity type, or profiles are not enabled and configured."
+ },
+ "404": {
+ "description": "No such entity in this project."
+ }
+ }
+ }
+ },
+ "/v2/profiles/samples/": {
+ "post": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_samples",
+ "summary": "Sample profiles to check a schema",
+ "description": "Generate profiles for a handful of real entities so a schema can be judged before it is applied project-wide.\n\nThese are real generations and the results are kept, so the work counts toward a later regenerate.",
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10,
+ "description": "How many entities to sample."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Sampling queued.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string"
+ },
+ "sampled": {
+ "type": "integer"
+ },
+ "results": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Profiles are not enabled and configured."
+ },
+ "429": {
+ "description": "A sample run was already started for this project very recently."
+ }
+ }
+ }
+ },
+ "/v2/profiles/regenerate/": {
+ "post": {
+ "tags": [
+ "profiles"
+ ],
+ "operationId": "profiles_regenerate",
+ "summary": "Rebuild every profile in the project",
+ "description": "Rebuild the profile of every entity in the project. This is how a new schema reaches entities that already have a profile.\n\nReturns 202 immediately; the rebuild runs in the background.",
+ "responses": {
+ "202": {
+ "description": "Regenerate queued.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string"
+ },
+ "message": {
+ "type": "string"
+ },
+ "project_id": {
+ "type": "string"
+ },
+ "existing_profile_count": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Profiles are not enabled and configured."
+ },
+ "409": {
+ "description": "A regenerate is already running for this project."
+ },
+ "429": {
+ "description": "A regenerate already ran for this project within the last hour."
+ }
+ }
+ }
}
},
"components": {
@@ -8988,4 +9359,4 @@
}
},
"x-original-swagger-version": "2.0"
-}
\ No newline at end of file
+}
diff --git a/docs/platform/features/user-profiles.mdx b/docs/platform/features/user-profiles.mdx
new file mode 100644
index 000000000..b8cb71310
--- /dev/null
+++ b/docs/platform/features/user-profiles.mdx
@@ -0,0 +1,272 @@
+---
+title: Profiles
+description: "Build a structured, always-current summary of each user or agent from their memories, shaped by a JSON Schema you define."
+icon: "id-card"
+---
+
+# Profiles
+
+Memories are individual facts. A profile is the summary of all of them for one entity: a single structured object, shaped by a JSON Schema you define, that Mem0 keeps current as new memories arrive.
+
+Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write.
+
+
+ **Use profiles when…**
+ - You want to personalize a first response, before the user says anything in this session.
+ - You need a compact object to drop into a prompt instead of a list of memories.
+ - You want the same fields for every user, so your code can rely on their shape.
+
+
+Profiles cover **users and agents**. An agent profile summarizes how an agent behaves, in the same way a user profile summarizes a person.
+
+## How it works
+
+1. You define a **schema**: the fields a profile should contain, each with a description.
+2. Mem0 builds each entity's profile from their memories, and rebuilds it as new memories arrive.
+3. You read the profile whenever you need it.
+
+Generation is **asynchronous**. A profile is not ready the instant an entity's first memory lands, so a read tells you where it is with a `status` rather than failing.
+
+## Define the schema
+
+The schema is JSON Schema. Every property needs a `description` — that is what tells the model how to fill the field, so a vague description gives a vague profile.
+
+
+```python Python
+from mem0 import MemoryClient
+
+client = MemoryClient()
+
+client.update_profile_settings(
+ enabled=True,
+ schema={
+ "type": "object",
+ "properties": {
+ "communication_style": {
+ "type": "string",
+ "description": "How the user prefers to be addressed: terse, detailed, formal, casual",
+ },
+ "expertise_areas": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Subjects the user demonstrates working knowledge of",
+ },
+ "current_goals": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "What the user is actively trying to accomplish",
+ },
+ },
+ },
+ custom_instructions="Prefer durable traits over one-off remarks.",
+)
+```
+
+```typescript TypeScript
+import MemoryClient from "mem0ai";
+
+const client = new MemoryClient({ apiKey: "your-api-key" });
+
+await client.updateProfileSettings({
+ enabled: true,
+ schema: {
+ type: "object",
+ properties: {
+ communication_style: {
+ type: "string",
+ description:
+ "How the user prefers to be addressed: terse, detailed, formal, casual",
+ },
+ expertise_areas: {
+ type: "array",
+ items: { type: "string" },
+ description: "Subjects the user demonstrates working knowledge of",
+ },
+ current_goals: {
+ type: "array",
+ items: { type: "string" },
+ description: "What the user is actively trying to accomplish",
+ },
+ },
+ },
+ customInstructions: "Prefer durable traits over one-off remarks.",
+});
+```
+
+
+
+ Your schema's property names reach the API exactly as you write them. The SDKs do not rewrite them, so a profile always comes back with the field names you chose.
+
+
+Only the fields you pass are written. To turn the feature off without touching your schema, send `enabled` alone.
+
+## Read a profile
+
+
+```python Python
+result = client.get_profile("alice")
+
+if result["status"] == "succeeded":
+ print(result["profile"])
+else:
+ print("not ready:", result["status"])
+```
+
+```typescript TypeScript
+const result = await client.getProfile({ entityId: "alice" });
+
+if (result.status === "succeeded") {
+ console.log(result.profile);
+} else {
+ console.log("not ready:", result.status);
+}
+```
+
+
+A response looks like this:
+
+```json
+{
+ "profile": {
+ "communication_style": "terse",
+ "expertise_areas": ["distributed systems", "postgres"],
+ "current_goals": ["cut p99 latency", "migrate off the legacy queue"]
+ },
+ "status": "succeeded",
+ "entity_type": "user",
+ "entity_id": "alice",
+ "updated_at": "2026-02-08T10:30:00Z",
+ "generation_count": 4
+}
+```
+
+### Always branch on `status`
+
+`profile` is empty unless `status` is `succeeded`. Check the status rather than the emptiness of the object, so a profile that is merely still building is not mistaken for a user you know nothing about.
+
+| `status` | Meaning | What to do |
+|---|---|---|
+| `succeeded` | Profile is built and current | Use it |
+| `pending` | Generation is queued or running | Read again shortly |
+| `insufficient_data` | Not enough memories to say anything yet | Fall back to defaults |
+| `not_enabled` | Profiles are off for this project | Enable them in settings |
+| `failed` | The last generation did not complete | Retry, or trigger a new one |
+
+A `404` means only that no such entity exists in your project.
+
+For an agent, pass the entity type:
+
+
+```python Python
+client.get_profile("support-bot", entity_type="agent")
+```
+
+```typescript TypeScript
+await client.getProfile({ entityId: "support-bot", entityType: "agent" });
+```
+
+
+## Generate a profile on demand
+
+Profiles are built once an entity has accumulated enough messages, so a brand-new user has none during their first few interactions. Trigger one directly to close that gap:
+
+
+```python Python
+client.generate_profile("alice")
+```
+
+```typescript TypeScript
+await client.generateProfile({ entityId: "alice" });
+```
+
+
+The call returns as soon as the work is queued. Poll the read endpoint and branch on `status`.
+
+## Test a schema before applying it
+
+A schema that reads well can still produce disappointing profiles. Sample a few real entities and inspect the output before committing to it:
+
+
+```python Python
+result = client.sample_profiles(limit=5)
+
+for row in result["results"]:
+ print(client.get_profile(row["entity_id"]))
+```
+
+```typescript TypeScript
+const result = await client.sampleProfiles({ limit: 5 });
+
+for (const row of result.results) {
+ console.log(await client.getProfile({ entityId: row.entityId }));
+}
+```
+
+
+These are real generations against real memories, and the results are kept — sampling is not wasted work. Sampling is limited to 10 entities per call and cannot be repeated immediately.
+
+## Apply a new schema to existing entities
+
+Changing the schema does not retroactively rewrite profiles that already exist. To rebuild every profile in the project:
+
+
+```python Python
+client.regenerate_profiles()
+```
+
+```typescript TypeScript
+await client.regenerateProfiles();
+```
+
+
+This returns immediately and runs in the background; a large project takes a while. It is limited to one run per project per hour, so sample first and regenerate once you are satisfied.
+
+## Use a profile in a prompt
+
+The point of the structure is that it drops straight into a prompt:
+
+```python
+result = client.get_profile(user_id)
+
+if result["status"] == "succeeded":
+ profile = result["profile"]
+ system_prompt = f"""You are helping {user_id}.
+Communication style: {profile.get("communication_style", "unknown")}
+Areas of expertise: {", ".join(profile.get("expertise_areas", []))}
+Current goals: {", ".join(profile.get("current_goals", []))}
+
+Match their style and do not explain what they already know."""
+else:
+ system_prompt = "You are a helpful assistant."
+```
+
+## Writing a schema that works
+
+- **Describe every field.** The description is the instruction; without it the model guesses.
+- **Prefer durable traits.** "Prefers dark mode" ages well; "is annoyed today" does not.
+- **Keep it small.** Ten focused fields beat forty speculative ones, and cost less to generate.
+- **Say what the field is not.** A description that rules out the near-miss interpretation is worth more than one that only states the obvious.
+- **Sample before you commit.** It is the only way to see what your descriptions actually produce.
+
+## Settings reference
+
+| Field | Type | Description |
+|---|---|---|
+| `enabled` | boolean | Whether profile generation runs for the project |
+| `schema` | object | JSON Schema describing the profile. Every property needs a `description` |
+| `custom_instructions` | string | Extra guidance applied during extraction |
+
+
+ Profile settings are per project. An API key is scoped to one project, so profiles never cross a project boundary.
+
+
+## Related
+
+
+
+ How users, agents, apps and runs partition memories.
+
+
+ Steer what Mem0 extracts in the first place.
+
+
diff --git a/mem0-ts/src/client/index.ts b/mem0-ts/src/client/index.ts
index 24ec5b8f4..98f1849a0 100644
--- a/mem0-ts/src/client/index.ts
+++ b/mem0-ts/src/client/index.ts
@@ -23,6 +23,14 @@ export type {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
+ ProfileEntityType,
+ ProfileStatus,
+ ProfileResponse,
+ ProfileTriggerResponse,
+ ProfileSettings,
+ ProfileSampleResult,
+ ProfileSamplesResponse,
+ ProfileRegenerateResponse,
} from "./mem0.types";
// Re-export enums as values (not type-only)
diff --git a/mem0-ts/src/client/mem0.ts b/mem0-ts/src/client/mem0.ts
index ff259f2fe..15368cc21 100644
--- a/mem0-ts/src/client/mem0.ts
+++ b/mem0-ts/src/client/mem0.ts
@@ -20,6 +20,12 @@ import {
FeedbackPayload,
CreateMemoryExportPayload,
GetMemoryExportPayload,
+ ProfileEntityType,
+ ProfileResponse,
+ ProfileTriggerResponse,
+ ProfileSettings,
+ ProfileSamplesResponse,
+ ProfileRegenerateResponse,
} from "./mem0.types";
import {
captureClientEvent,
@@ -244,7 +250,8 @@ export default class MemoryClient {
});
}
- async _fetchWithErrorHandling(url: string, options: any): Promise {
+ /** Fetch with no key conversion, for payloads carrying user-controlled property names. */
+ async _fetchRawJson(url: string, options: any): Promise {
const response = await fetch(url, {
...options,
headers: {
@@ -257,8 +264,11 @@ export default class MemoryClient {
const errorData = await response.text();
throw createExceptionFromResponse(response.status, errorData);
}
- const jsonResponse = await response.json();
- return snakeToCamelKeys(jsonResponse);
+ return response.json();
+ }
+
+ async _fetchWithErrorHandling(url: string, options: any): Promise {
+ return snakeToCamelKeys(await this._fetchRawJson(url, options));
}
_preparePayload(
@@ -755,6 +765,142 @@ export default class MemoryClient {
return response;
}
+ /**
+ * Get the memory profile for a single entity.
+ *
+ * Branch on `status`, not on an empty `profile`: generation is asynchronous,
+ * so a known entity without a profile yet is a normal response.
+ */
+ async getProfile(data: {
+ entityId: string;
+ entityType?: ProfileEntityType;
+ }): Promise {
+ this._captureEvent("get_profile", []);
+ await this._awaitIdentity();
+
+ const entityType = data.entityType ?? "user";
+ const response = await this._fetchWithErrorHandling(
+ `${this.host}/v2/entities/${encodeURIComponent(entityType)}/${encodeURIComponent(data.entityId)}/profile/`,
+ {
+ headers: this.headers,
+ },
+ );
+ return response;
+ }
+
+ /**
+ * Generate or refresh the profile for one entity, now.
+ *
+ * Profiles are otherwise built once an entity crosses an internal message
+ * threshold, so a new entity has none for its first few memories. Returns as
+ * soon as the work is queued: poll {@link getProfile} and branch on `status`.
+ */
+ async generateProfile(data: {
+ entityId: string;
+ entityType?: ProfileEntityType;
+ }): Promise {
+ this._captureEvent("generate_profile", []);
+ await this._awaitIdentity();
+
+ const response = await this._fetchWithErrorHandling(
+ `${this.host}/v2/profiles/trigger/`,
+ {
+ method: "POST",
+ headers: this.headers,
+ body: JSON.stringify({
+ entity_type: data.entityType ?? "user",
+ entity_id: data.entityId,
+ }),
+ },
+ );
+ return response;
+ }
+
+ /** Get the profile settings for the current project. */
+ async getProfileSettings(): Promise {
+ this._captureEvent("get_profile_settings", []);
+ await this._awaitIdentity();
+
+ const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
+ headers: this.headers,
+ });
+ return this._settingsWithVerbatimSchema(raw);
+ }
+
+ /** The envelope keys are ours; the schema's property names are the customer's. */
+ private _settingsWithVerbatimSchema(raw: any): ProfileSettings {
+ const settings = snakeToCamelKeys(raw) as ProfileSettings;
+ if (raw && typeof raw === "object" && "schema" in raw) {
+ settings.schema = raw.schema;
+ }
+ return settings;
+ }
+
+ /** Update profile settings. Only the fields you pass are written. */
+ async updateProfileSettings(
+ settings: ProfileSettings,
+ ): Promise {
+ const payloadKeys = Object.keys(settings || {});
+ this._captureEvent("update_profile_settings", [payloadKeys]);
+ await this._awaitIdentity();
+
+ // The schema's property names are the customer's and must reach the API verbatim.
+ const { schema, ...rest } = settings;
+ const body: Record = camelToSnakeKeys(rest);
+ if (schema !== undefined) {
+ body.schema = schema;
+ }
+
+ const raw = await this._fetchRawJson(`${this.host}/v2/profiles/settings/`, {
+ method: "POST",
+ headers: this.headers,
+ body: JSON.stringify(body),
+ });
+ return this._settingsWithVerbatimSchema(raw);
+ }
+
+ /**
+ * Generate profiles for a few real entities, to check a schema.
+ *
+ * Real generations against real memories, and the results are kept.
+ */
+ async sampleProfiles(data?: {
+ limit?: number;
+ }): Promise {
+ this._captureEvent("sample_profiles", []);
+ await this._awaitIdentity();
+
+ const response = await this._fetchWithErrorHandling(
+ `${this.host}/v2/profiles/samples/`,
+ {
+ method: "POST",
+ headers: this.headers,
+ body: JSON.stringify(this._prepareParams({ limit: data?.limit })),
+ },
+ );
+ return response;
+ }
+
+ /**
+ * Rebuild the profile of every entity in the project.
+ *
+ * This is how a new schema reaches entities that already have a profile.
+ */
+ async regenerateProfiles(): Promise {
+ this._captureEvent("regenerate_profiles", []);
+ await this._awaitIdentity();
+
+ const response = await this._fetchWithErrorHandling(
+ `${this.host}/v2/profiles/regenerate/`,
+ {
+ method: "POST",
+ headers: this.headers,
+ body: JSON.stringify({}),
+ },
+ );
+ return response;
+ }
+
async createMemoryExport(
data: CreateMemoryExportPayload,
): Promise<{ message: string; id: string }> {
diff --git a/mem0-ts/src/client/mem0.types.ts b/mem0-ts/src/client/mem0.types.ts
index c230441e0..cdbcd1c4d 100644
--- a/mem0-ts/src/client/mem0.types.ts
+++ b/mem0-ts/src/client/mem0.types.ts
@@ -236,3 +236,57 @@ export interface GetMemoryExportPayload {
filters?: Record;
memoryExportId?: string;
}
+
+// ─── Profile Types ──────────────────────────────────────────
+
+/** Entity kinds that can carry a profile. */
+export type ProfileEntityType = "user" | "agent";
+
+/** `succeeded` is the only state in which `profile` is guaranteed to hold content. */
+export type ProfileStatus =
+ "succeeded" | "pending" | "failed" | "notEnabled" | "insufficientData";
+
+export interface ProfileResponse {
+ /** Shaped by the project's schema; keys are not camel-cased. */
+ profile: Record;
+ status: ProfileStatus;
+ entityType: ProfileEntityType;
+ entityId: string;
+ updatedAt: string | null;
+ generationCount: number;
+}
+
+export interface ProfileTriggerResponse {
+ message: string;
+ entityType: ProfileEntityType;
+ entityId: string;
+ profileId: string;
+ status: string;
+}
+
+export interface ProfileSettings {
+ enabled?: boolean;
+ /** JSON Schema for the profile. Every property needs a `description`. */
+ schema?: Record | null;
+ customInstructions?: string | null;
+}
+
+export interface ProfileSampleResult {
+ entityType: ProfileEntityType;
+ entityId: string;
+ profileId?: string;
+ [key: string]: any;
+}
+
+export interface ProfileSamplesResponse {
+ message: string;
+ sampled: number;
+ results: Array;
+}
+
+export interface ProfileRegenerateResponse {
+ status: string;
+ message: string;
+ projectId: string;
+ existingProfileCount: number;
+}
diff --git a/mem0-ts/src/client/tests/memoryClient.profiles.test.ts b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts
new file mode 100644
index 000000000..d09aa0697
--- /dev/null
+++ b/mem0-ts/src/client/tests/memoryClient.profiles.test.ts
@@ -0,0 +1,238 @@
+/**
+ * MemoryClient unit tests — profiles.
+ * Verifies request construction and the verbatim round-trip of user-controlled
+ * profile/schema keys, not mock response echo.
+ */
+import { MemoryClient } from "../mem0";
+import { TEST_API_KEY } from "./helpers";
+import {
+ setupMockFetch,
+ findFetchCall,
+ getFetchBody,
+ installConsoleSuppression,
+} from "./setup";
+
+installConsoleSuppression();
+
+describe("MemoryClient - getProfile()", () => {
+ test("reads the v2 entity route and keeps profile keys verbatim", async () => {
+ const extra = new Map();
+ extra.set("/v2/entities/user/alice/profile/", {
+ status: 200,
+ body: {
+ // Customer schema keys: camel-casing these would break the schema they wrote.
+ profile: {
+ favorite_topics: ["hiking"],
+ work_style: { preferred_hours: "mornings" },
+ },
+ status: "succeeded",
+ entity_type: "user",
+ entity_id: "alice",
+ updated_at: "2026-02-08T00:00:00Z",
+ generation_count: 3,
+ },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.getProfile({ entityId: "alice" });
+
+ const call = findFetchCall(mock, "/v2/entities/user/alice/profile/");
+ expect(call).toBeDefined();
+
+ expect(result.profile).toEqual({
+ favorite_topics: ["hiking"],
+ work_style: { preferred_hours: "mornings" },
+ });
+ expect(result.entityType).toBe("user");
+ expect(result.entityId).toBe("alice");
+ expect(result.generationCount).toBe(3);
+ expect(result.status).toBe("succeeded");
+ });
+
+ test("defaults to user and encodes the entity id", async () => {
+ const extra = new Map();
+ extra.set("/v2/entities/agent/", {
+ status: 200,
+ body: { profile: {}, status: "pending", entity_type: "agent" },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ await client.getProfile({ entityId: "a/b", entityType: "agent" });
+
+ const call = findFetchCall(mock, "/v2/entities/agent/a%2Fb/profile/");
+ expect(call).toBeDefined();
+ });
+});
+
+describe("MemoryClient - generateProfile()", () => {
+ test("posts entity_type and entity_id to the trigger route", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/trigger/", {
+ status: 202,
+ body: {
+ message: "Profile generation started.",
+ entity_type: "user",
+ entity_id: "alice",
+ profile_id: "p_1",
+ status: "PENDING",
+ },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.generateProfile({ entityId: "alice" });
+
+ const call = findFetchCall(mock, "/v2/profiles/trigger/", "POST");
+ expect(call).toBeDefined();
+ const body = getFetchBody(call!);
+ expect(body.entity_type).toBe("user");
+ expect(body.entity_id).toBe("alice");
+ expect(result.profileId).toBe("p_1");
+ });
+});
+
+describe("MemoryClient - profile settings", () => {
+ test("sends schema property names verbatim and returns them unchanged", async () => {
+ const schema = {
+ type: "object",
+ properties: {
+ favorite_topics: {
+ type: "array",
+ description: "Topics the user returns to",
+ items: { type: "string" },
+ },
+ workStyle: {
+ type: "string",
+ description: "How the user prefers to work",
+ },
+ },
+ };
+
+ const extra = new Map();
+ extra.set("/v2/profiles/settings/", {
+ status: 200,
+ body: {
+ enabled: true,
+ schema,
+ custom_instructions: "Focus on durable preferences",
+ },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.updateProfileSettings({
+ enabled: true,
+ schema,
+ customInstructions: "Focus on durable preferences",
+ });
+
+ const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
+ expect(call).toBeDefined();
+ const body = getFetchBody(call!);
+
+ // Mixed casing goes out exactly as written.
+ expect(body.schema).toEqual(schema);
+ expect(body.custom_instructions).toBe("Focus on durable preferences");
+ expect(body.enabled).toBe(true);
+ expect(result.schema).toEqual(schema);
+ expect(result.customInstructions).toBe("Focus on durable preferences");
+ });
+
+ test("omits fields the caller did not set", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/settings/", {
+ status: 200,
+ body: { enabled: false, schema: null, custom_instructions: null },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ await client.updateProfileSettings({ enabled: false });
+
+ const call = findFetchCall(mock, "/v2/profiles/settings/", "POST");
+ const body = getFetchBody(call!);
+ expect(body.enabled).toBe(false);
+ expect("schema" in body).toBe(false);
+ expect("custom_instructions" in body).toBe(false);
+ });
+
+ test("getProfileSettings reads the v2 route", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/settings/", {
+ status: 200,
+ body: {
+ enabled: true,
+ schema: { properties: { favorite_topics: { type: "array" } } },
+ custom_instructions: null,
+ },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.getProfileSettings();
+
+ expect(findFetchCall(mock, "/v2/profiles/settings/")).toBeDefined();
+ expect(result.enabled).toBe(true);
+ expect(result.schema).toEqual({
+ properties: { favorite_topics: { type: "array" } },
+ });
+ });
+});
+
+describe("MemoryClient - sampleProfiles() / regenerateProfiles()", () => {
+ test("sampleProfiles omits limit when unset", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/samples/", {
+ status: 202,
+ body: { message: "Sampling 5 users.", sampled: 5, results: [] },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ await client.sampleProfiles();
+
+ const call = findFetchCall(mock, "/v2/profiles/samples/", "POST");
+ expect(getFetchBody(call!)).toEqual({});
+ });
+
+ test("sampleProfiles passes an explicit limit", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/samples/", {
+ status: 202,
+ body: { message: "Sampling 3 users.", sampled: 3, results: [] },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.sampleProfiles({ limit: 3 });
+
+ const call = findFetchCall(mock, "/v2/profiles/samples/", "POST");
+ expect(getFetchBody(call!).limit).toBe(3);
+ expect(result.sampled).toBe(3);
+ });
+
+ test("regenerateProfiles posts to the regenerate route", async () => {
+ const extra = new Map();
+ extra.set("/v2/profiles/regenerate/", {
+ status: 202,
+ body: {
+ status: "accepted",
+ message: "Regenerating profiles.",
+ project_id: "proj_abc",
+ existing_profile_count: 12,
+ },
+ });
+ const mock = setupMockFetch(extra);
+
+ const client = new MemoryClient({ apiKey: TEST_API_KEY });
+ const result = await client.regenerateProfiles();
+
+ expect(
+ findFetchCall(mock, "/v2/profiles/regenerate/", "POST"),
+ ).toBeDefined();
+ expect(result.existingProfileCount).toBe(12);
+ expect(result.projectId).toBe("proj_abc");
+ });
+});
diff --git a/mem0-ts/src/client/utils.ts b/mem0-ts/src/client/utils.ts
index aca9ffbcd..8630ad470 100644
--- a/mem0-ts/src/client/utils.ts
+++ b/mem0-ts/src/client/utils.ts
@@ -34,6 +34,9 @@ const OPAQUE_VALUE_KEYS = new Set([
// (see issue #5738; same class as `metadata`/`structuredDataSchema`).
"customCategories",
"custom_categories",
+ // A profile's keys come from the customer's own JSON Schema. The schema itself
+ // is handled in `updateProfileSettings`, so that `schema` is not opaque globally.
+ "profile",
]);
/**
diff --git a/mem0/client/main.py b/mem0/client/main.py
index a52986dcd..9bce4e3d7 100644
--- a/mem0/client/main.py
+++ b/mem0/client/main.py
@@ -682,6 +682,155 @@ class MemoryClient:
capture_client_event("client.get_summary", self, {"sync_type": "sync"})
return response.json()
+ @api_error_handler
+ def get_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
+ """Get the memory profile for a single entity.
+
+ Branch on ``status``, not on an empty ``profile``: generation is
+ asynchronous, so a known entity without a profile yet is a normal response.
+
+ Args:
+ entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice").
+ entity_type: Either "user" or "agent". Defaults to "user".
+
+ Returns:
+ Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
+ ``updated_at`` and ``generation_count``. ``status`` is one of
+ "succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
+
+ Raises:
+ ValidationError: If entity_type is not a supported entity kind.
+ AuthenticationError: If authentication fails.
+ NotFoundError: If no such entity exists in the project.
+ """
+
+ response = self.client.get(
+ f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/"
+ )
+ response.raise_for_status()
+ capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "sync"})
+ return response.json()
+
+ @api_error_handler
+ def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
+ """Generate or refresh the profile for a single entity, now.
+
+ Profiles are otherwise built once an entity crosses an internal message
+ threshold, so a new entity has none for its first few memories. Returns as
+ soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
+
+ Args:
+ entity_id: The entity's id, as you supplied it on ``add``.
+ entity_type: Either "user" or "agent". Defaults to "user".
+
+ Returns:
+ Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
+ ``status``.
+
+ Raises:
+ ValidationError: If entity_type is unsupported or profiles are not
+ enabled and configured for the project.
+ NotFoundError: If no such entity exists in the project.
+ """
+
+ response = self.client.post(
+ "/v2/profiles/trigger/",
+ json={"entity_type": entity_type, "entity_id": entity_id},
+ )
+ response.raise_for_status()
+ capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "sync"})
+ return response.json()
+
+ @api_error_handler
+ def get_profile_settings(self) -> Dict[str, Any]:
+ """Get the profile settings for the current project.
+
+ Returns:
+ Dict with ``enabled``, ``schema`` and ``custom_instructions``.
+ """
+
+ response = self.client.get("/v2/profiles/settings/")
+ response.raise_for_status()
+ capture_client_event("client.get_profile_settings", self, {"sync_type": "sync"})
+ return response.json()
+
+ @api_error_handler
+ def update_profile_settings(
+ self,
+ enabled: Optional[bool] = None,
+ schema: Optional[Dict[str, Any]] = None,
+ custom_instructions: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Update the profile settings for the current project.
+
+ Only the arguments you pass are written.
+
+ Args:
+ enabled: Turn profile generation on or off.
+ schema: JSON Schema for the profile. Every property needs a
+ ``description``.
+ custom_instructions: Extra guidance for the extraction step.
+
+ Returns:
+ Dict with the settings as stored after the update.
+
+ Raises:
+ ValidationError: If the schema is not a valid profile schema.
+ """
+
+ payload = self._prepare_params(
+ {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions}
+ )
+ response = self.client.post("/v2/profiles/settings/", json=payload)
+ response.raise_for_status()
+ capture_client_event(
+ "client.update_profile_settings", self, {"keys": list(payload.keys()), "sync_type": "sync"}
+ )
+ return response.json()
+
+ @api_error_handler
+ def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
+ """Generate profiles for a few real entities, to check a schema.
+
+ Real generations against real memories, and the results are kept.
+
+ Args:
+ limit: How many entities to sample, 1-10. Defaults to the server value.
+
+ Returns:
+ Dict containing ``sampled`` and one ``results`` row per entity.
+
+ Raises:
+ ValidationError: If profiles are not enabled and configured.
+ RateLimitError: If a sample run was already started very recently.
+ """
+
+ response = self.client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit}))
+ response.raise_for_status()
+ capture_client_event("client.sample_profiles", self, {"sync_type": "sync"})
+ return response.json()
+
+ @api_error_handler
+ def regenerate_profiles(self) -> Dict[str, Any]:
+ """Rebuild the profile of every entity in the current project.
+
+ This is how a new schema reaches entities that already have a profile.
+ Returns as soon as the work is queued.
+
+ Returns:
+ Dict containing ``status``, ``message``, ``project_id`` and
+ ``existing_profile_count``.
+
+ Raises:
+ ValidationError: If profiles are not enabled and configured.
+ RateLimitError: If a regenerate already ran for this project recently.
+ """
+
+ response = self.client.post("/v2/profiles/regenerate/", json={})
+ response.raise_for_status()
+ capture_client_event("client.regenerate_profiles", self, {"sync_type": "sync"})
+ return response.json()
+
@api_error_handler
def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Get instructions or categories for the current project.
@@ -1588,6 +1737,155 @@ class AsyncMemoryClient:
capture_client_event("client.get_summary", self, {"sync_type": "async"})
return response.json()
+ @api_error_handler
+ async def get_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
+ """Get the memory profile for a single entity.
+
+ Branch on ``status``, not on an empty ``profile``: generation is
+ asynchronous, so a known entity without a profile yet is a normal response.
+
+ Args:
+ entity_id: The entity's id, as you supplied it on ``add`` (e.g. "alice").
+ entity_type: Either "user" or "agent". Defaults to "user".
+
+ Returns:
+ Dict with ``profile``, ``status``, ``entity_type``, ``entity_id``,
+ ``updated_at`` and ``generation_count``. ``status`` is one of
+ "succeeded", "pending", "failed", "not_enabled" or "insufficient_data".
+
+ Raises:
+ ValidationError: If entity_type is not a supported entity kind.
+ AuthenticationError: If authentication fails.
+ NotFoundError: If no such entity exists in the project.
+ """
+
+ response = await self.async_client.get(
+ f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/profile/"
+ )
+ response.raise_for_status()
+ capture_client_event("client.get_profile", self, {"entity_type": entity_type, "sync_type": "async"})
+ return response.json()
+
+ @api_error_handler
+ async def generate_profile(self, entity_id: str, entity_type: str = "user") -> Dict[str, Any]:
+ """Generate or refresh the profile for a single entity, now.
+
+ Profiles are otherwise built once an entity crosses an internal message
+ threshold, so a new entity has none for its first few memories. Returns as
+ soon as the work is queued: poll :meth:`get_profile` and branch on ``status``.
+
+ Args:
+ entity_id: The entity's id, as you supplied it on ``add``.
+ entity_type: Either "user" or "agent". Defaults to "user".
+
+ Returns:
+ Dict containing ``profile_id``, ``entity_type``, ``entity_id`` and
+ ``status``.
+
+ Raises:
+ ValidationError: If entity_type is unsupported or profiles are not
+ enabled and configured for the project.
+ NotFoundError: If no such entity exists in the project.
+ """
+
+ response = await self.async_client.post(
+ "/v2/profiles/trigger/",
+ json={"entity_type": entity_type, "entity_id": entity_id},
+ )
+ response.raise_for_status()
+ capture_client_event("client.generate_profile", self, {"entity_type": entity_type, "sync_type": "async"})
+ return response.json()
+
+ @api_error_handler
+ async def get_profile_settings(self) -> Dict[str, Any]:
+ """Get the profile settings for the current project.
+
+ Returns:
+ Dict with ``enabled``, ``schema`` and ``custom_instructions``.
+ """
+
+ response = await self.async_client.get("/v2/profiles/settings/")
+ response.raise_for_status()
+ capture_client_event("client.get_profile_settings", self, {"sync_type": "async"})
+ return response.json()
+
+ @api_error_handler
+ async def update_profile_settings(
+ self,
+ enabled: Optional[bool] = None,
+ schema: Optional[Dict[str, Any]] = None,
+ custom_instructions: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ """Update the profile settings for the current project.
+
+ Only the arguments you pass are written.
+
+ Args:
+ enabled: Turn profile generation on or off.
+ schema: JSON Schema for the profile. Every property needs a
+ ``description``.
+ custom_instructions: Extra guidance for the extraction step.
+
+ Returns:
+ Dict with the settings as stored after the update.
+
+ Raises:
+ ValidationError: If the schema is not a valid profile schema.
+ """
+
+ payload = self._prepare_params(
+ {"enabled": enabled, "schema": schema, "custom_instructions": custom_instructions}
+ )
+ response = await self.async_client.post("/v2/profiles/settings/", json=payload)
+ response.raise_for_status()
+ capture_client_event(
+ "client.update_profile_settings", self, {"keys": list(payload.keys()), "sync_type": "async"}
+ )
+ return response.json()
+
+ @api_error_handler
+ async def sample_profiles(self, limit: Optional[int] = None) -> Dict[str, Any]:
+ """Generate profiles for a few real entities, to check a schema.
+
+ Real generations against real memories, and the results are kept.
+
+ Args:
+ limit: How many entities to sample, 1-10. Defaults to the server value.
+
+ Returns:
+ Dict containing ``sampled`` and one ``results`` row per entity.
+
+ Raises:
+ ValidationError: If profiles are not enabled and configured.
+ RateLimitError: If a sample run was already started very recently.
+ """
+
+ response = await self.async_client.post("/v2/profiles/samples/", json=self._prepare_params({"limit": limit}))
+ response.raise_for_status()
+ capture_client_event("client.sample_profiles", self, {"sync_type": "async"})
+ return response.json()
+
+ @api_error_handler
+ async def regenerate_profiles(self) -> Dict[str, Any]:
+ """Rebuild the profile of every entity in the current project.
+
+ This is how a new schema reaches entities that already have a profile.
+ Returns as soon as the work is queued.
+
+ Returns:
+ Dict containing ``status``, ``message``, ``project_id`` and
+ ``existing_profile_count``.
+
+ Raises:
+ ValidationError: If profiles are not enabled and configured.
+ RateLimitError: If a regenerate already ran for this project recently.
+ """
+
+ response = await self.async_client.post("/v2/profiles/regenerate/", json={})
+ response.raise_for_status()
+ capture_client_event("client.regenerate_profiles", self, {"sync_type": "async"})
+ return response.json()
+
@api_error_handler
async def get_project(self, fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Get instructions or categories for the current project.
diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py
new file mode 100644
index 000000000..352af1ffb
--- /dev/null
+++ b/tests/test_client_profiles.py
@@ -0,0 +1,224 @@
+"""Tests for the MemoryClient profile methods.
+
+These assert request construction — path, verb, body — rather than echoing a
+mocked response back. The profile payload itself is the customer's own JSON
+Schema shape, so the tests also pin that the SDK passes it through untouched.
+"""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture
+def mock_memory_client():
+ """A MemoryClient whose transport is mocked."""
+ with patch("mem0.client.main.httpx.Client") as mock_httpx:
+ mock_http_client = MagicMock()
+ mock_http_client.get.return_value = MagicMock(
+ json=lambda: {"org_id": "org1", "project_id": "proj1", "user_email": "test@test.com"},
+ raise_for_status=lambda: None,
+ )
+ mock_httpx.return_value = mock_http_client
+
+ with patch("mem0.client.main.capture_client_event"):
+ from mem0.client.main import MemoryClient
+
+ client = MemoryClient(api_key="test-api-key")
+ # The constructor pings through this same mock; drop that call.
+ mock_http_client.get.reset_mock()
+ yield client
+
+
+def _mock_response(payload):
+ response = MagicMock()
+ response.json.return_value = payload
+ response.raise_for_status.return_value = None
+ return response
+
+
+class TestGetProfile:
+ def test_reads_the_v2_entity_route(self, mock_memory_client):
+ mock_memory_client.client.get.return_value = _mock_response(
+ {"profile": {}, "status": "pending", "entity_type": "user", "entity_id": "alice"}
+ )
+
+ mock_memory_client.get_profile("alice")
+
+ mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
+
+ def test_supports_agents(self, mock_memory_client):
+ mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
+
+ mock_memory_client.get_profile("support-bot", entity_type="agent")
+
+ mock_memory_client.client.get.assert_called_once_with("/v2/entities/agent/support-bot/profile/")
+
+ def test_encodes_path_segments(self, mock_memory_client):
+ """An id with a slash must not open a new path segment."""
+ mock_memory_client.client.get.return_value = _mock_response({"profile": {}, "status": "pending"})
+
+ mock_memory_client.get_profile("tenant/alice")
+
+ mock_memory_client.client.get.assert_called_once_with("/v2/entities/user/tenant%2Falice/profile/")
+
+ def test_returns_the_envelope_verbatim(self, mock_memory_client):
+ """The customer's schema keys reach the caller exactly as stored."""
+ payload = {
+ "profile": {"favorite_topics": ["hiking"], "work_style": {"preferred_hours": "mornings"}},
+ "status": "succeeded",
+ "entity_type": "user",
+ "entity_id": "alice",
+ "updated_at": "2026-02-08T00:00:00Z",
+ "generation_count": 3,
+ }
+ mock_memory_client.client.get.return_value = _mock_response(payload)
+
+ assert mock_memory_client.get_profile("alice") == payload
+
+
+class TestGenerateProfile:
+ def test_posts_entity_type_and_id(self, mock_memory_client):
+ mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_1", "status": "PENDING"})
+
+ mock_memory_client.generate_profile("alice")
+
+ mock_memory_client.client.post.assert_called_once_with(
+ "/v2/profiles/trigger/",
+ json={"entity_type": "user", "entity_id": "alice"},
+ )
+
+ def test_agent_entity_type(self, mock_memory_client):
+ mock_memory_client.client.post.return_value = _mock_response({"profile_id": "p_2", "status": "PENDING"})
+
+ mock_memory_client.generate_profile("support-bot", entity_type="agent")
+
+ mock_memory_client.client.post.assert_called_once_with(
+ "/v2/profiles/trigger/",
+ json={"entity_type": "agent", "entity_id": "support-bot"},
+ )
+
+
+class TestProfileSettings:
+ def test_get_reads_v2(self, mock_memory_client):
+ mock_memory_client.client.get.return_value = _mock_response(
+ {"enabled": True, "schema": None, "custom_instructions": None}
+ )
+
+ mock_memory_client.get_profile_settings()
+
+ mock_memory_client.client.get.assert_called_once_with("/v2/profiles/settings/")
+
+ def test_update_sends_only_supplied_fields(self, mock_memory_client):
+ """A partial update must not blank the fields it never mentions."""
+ mock_memory_client.client.post.return_value = _mock_response({"enabled": False})
+
+ mock_memory_client.update_profile_settings(enabled=False)
+
+ mock_memory_client.client.post.assert_called_once_with(
+ "/v2/profiles/settings/",
+ json={"enabled": False},
+ )
+
+ def test_update_passes_schema_verbatim(self, mock_memory_client):
+ schema = {
+ "type": "object",
+ "properties": {
+ "favorite_topics": {
+ "type": "array",
+ "description": "Topics the user returns to",
+ "items": {"type": "string"},
+ }
+ },
+ }
+ mock_memory_client.client.post.return_value = _mock_response({"enabled": True, "schema": schema})
+
+ mock_memory_client.update_profile_settings(enabled=True, schema=schema, custom_instructions="Keep it durable")
+
+ mock_memory_client.client.post.assert_called_once_with(
+ "/v2/profiles/settings/",
+ json={"enabled": True, "schema": schema, "custom_instructions": "Keep it durable"},
+ )
+
+
+class TestSampleAndRegenerate:
+ def test_sample_without_limit(self, mock_memory_client):
+ mock_memory_client.client.post.return_value = _mock_response({"sampled": 5, "results": []})
+
+ mock_memory_client.sample_profiles()
+
+ mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={})
+
+ def test_sample_with_limit(self, mock_memory_client):
+ mock_memory_client.client.post.return_value = _mock_response({"sampled": 3, "results": []})
+
+ mock_memory_client.sample_profiles(limit=3)
+
+ mock_memory_client.client.post.assert_called_once_with("/v2/profiles/samples/", json={"limit": 3})
+
+ def test_regenerate(self, mock_memory_client):
+ mock_memory_client.client.post.return_value = _mock_response(
+ {"status": "accepted", "project_id": "proj_abc", "existing_profile_count": 12}
+ )
+
+ mock_memory_client.regenerate_profiles()
+
+ mock_memory_client.client.post.assert_called_once_with("/v2/profiles/regenerate/", json={})
+
+
+class TestAsyncClientParity:
+ """The async client must speak the same wire protocol as the sync one."""
+
+ @pytest.fixture
+ def async_client(self):
+ # AsyncMemoryClient validates the key synchronously, through requests.
+ validation = MagicMock()
+ validation.json.return_value = {
+ "org_id": "org1",
+ "project_id": "proj1",
+ "user_email": "test@test.com",
+ }
+ validation.raise_for_status.return_value = None
+
+ with patch("mem0.client.main.httpx.AsyncClient") as mock_httpx:
+ mock_httpx.return_value = MagicMock()
+ with patch("mem0.client.main.requests.get", return_value=validation):
+ with patch("mem0.client.main.capture_client_event"):
+ from mem0.client.main import AsyncMemoryClient
+
+ yield AsyncMemoryClient(api_key="test-api-key")
+
+ def test_get_profile(self, async_client):
+ async_client.async_client.get = AsyncMock(return_value=_mock_response({"profile": {}, "status": "pending"}))
+
+ asyncio.run(async_client.get_profile("alice"))
+
+ async_client.async_client.get.assert_called_once_with("/v2/entities/user/alice/profile/")
+
+ def test_generate_profile(self, async_client):
+ async_client.async_client.post = AsyncMock(return_value=_mock_response({"profile_id": "p_1"}))
+
+ asyncio.run(async_client.generate_profile("alice", entity_type="agent"))
+
+ async_client.async_client.post.assert_called_once_with(
+ "/v2/profiles/trigger/",
+ json={"entity_type": "agent", "entity_id": "alice"},
+ )
+
+ def test_update_settings_partial(self, async_client):
+ async_client.async_client.post = AsyncMock(return_value=_mock_response({"enabled": True}))
+
+ asyncio.run(async_client.update_profile_settings(enabled=True))
+
+ async_client.async_client.post.assert_called_once_with(
+ "/v2/profiles/settings/",
+ json={"enabled": True},
+ )
+
+ def test_regenerate(self, async_client):
+ async_client.async_client.post = AsyncMock(return_value=_mock_response({"status": "accepted"}))
+
+ asyncio.run(async_client.regenerate_profiles())
+
+ async_client.async_client.post.assert_called_once_with("/v2/profiles/regenerate/", json={})