Files
mem0/examples/notebooks/user-profiles.ipynb
T
Pratik 949b026991 fix(profiles): correct the settings shape, job entity_type and status type
Four bugs that made the profile SDK unusable against the live API, each
found by running the demo notebook end to end rather than by reading.

1. `update_profile_settings()` sent a flat body:

       {"enabled": ..., "schema": ..., "custom_instructions": ...}

   The API takes only `enabled` and `entities` at the top level and
   answers 400 "Unsupported settings: custom_instructions, schema." to
   anything else, so every call passing a schema failed.
   `get_profile_settings()` already returned the nested shape, so the read
   and the write disagreed and the method could not round-trip its own
   settings. Both SDKs now nest `schema` and `custom_instructions` under
   `entities.<entity_type>` while keeping the flat call signature;
   `entity_type` is a new optional argument defaulting to "user".

2. `sample_profiles()` and `regenerate_profiles()` never sent
   `entity_type`. Every profile job must name an entity kind, so both
   failed with "entity_type must be one of: user, agent."

3. The TypeScript read path rewrote the customer's schema property names.
   Once the schema moved under `entities`, `snakeToCamelKeys` camel-cased
   the keys inside it, because only a top-level schema was restored
   verbatim. A field named `favorite_topics` came back as `favoriteTopics`.

4. `ProfileStatus` declared `notEnabled` and `insufficientData`, but a
   status is a value, not a key, so it is never camel-cased. tsc rejected
   `status === "insufficient_data"`, which is true at runtime, and accepted
   `status === "insufficientData"`, which can never fire. Branching on
   status is the documented way to use a profile, so the type steered
   every TypeScript caller into a dead branch.

Response-shape corrections found alongside them: sample returns
`entity_ids` on create and a richer `results` array on the job, so the TS
`results` field on the create response is marked deprecated and never set;
regenerate answers 409, not the 501 the docstrings claimed.

Verified against a live environment, from both SDKs: a settings write
followed by a read returns the schema property for property, a partial
update no longer blanks it, sample returns 202 with the entities it
picked, regenerate reaches the server and answers its real
not_yet_available, and a user with no profile returns "insufficient_data".

Adds a user-profiles demo notebook covering the whole loop: schema,
ingestion, generation, a before/after diff of a profile rewriting itself,
schema sampling, and a failure-scenario section for each way the API says
no. It polls the add event to a terminal status instead of sleeping, waits
for the extracted memory count to settle rather than trusting the first
page, and reports plainly when generation cannot finish instead of
presenting an empty profile as a result. Executed end to end: 0 failing
cells.

- python: 20 passed (tests/test_client_profiles.py)
- typescript: 203 passed (src/client/tests/)
2026-09-18 17:10:31 -07:00

33 KiB

User Profiles — live demo

A profile is a structured JSON document about ONE user, filled by an LLM from that user's memories, shaped by a JSON Schema you supply.

Search answers "what did this user say about X". A profile answers "who is this user", in one read, with no query to write — and it is available on the first turn of a session, before the user has said anything.

What this notebook does: feed a user 12 conversation turns, watch a profile get generated from them, add 6 more turns that contradict the first set, and watch the profile rewrite itself. Then it shows every way the API says no.

You need: an API key, and a project on the Pro plan or higher. Never commit one.

Set MEM0_API_KEY, and MEM0_API_HOST if you are pointing at a sandbox rather than production. The cells below read both from the environment.

In [ ]:
# This notebook drives the SDK from this worktree, not the published mem0ai:
# the profile fixes below are not released yet.
%pip install -q -e ../..
In [ ]:
import json
import os
import time
import uuid

import mem0
from mem0 import MemoryClient

API_KEY = os.environ.get("MEM0_API_KEY")
if not API_KEY:
    import getpass

    API_KEY = getpass.getpass("API key: ")

client = MemoryClient(api_key=API_KEY, host=os.environ.get("MEM0_API_HOST") or None)

# Fresh id each run, so nothing below is stale from a previous pass.
USER_ID = f"demo_{uuid.uuid4().hex[:8]}"

print("sdk      :", mem0.__file__)  # must be this worktree
print("host     :", client.host)
print("demo user:", USER_ID)

1. Define the schema

The schema is handed to the model as a tool definition, and each field's description is the only instruction the model gets about what belongs there. An undescribed field is a field the model guesses at.

Rules worth knowing:

  • root type: object with a non-empty properties — an empty one is refused, because it would bill you to extract nothing
  • the root keys _profile_config_version and entities are reserved and rejected: they name the storage envelope, so a schema using them could not be read back unambiguously
  • keep it small. The whole schema is sent to the model on every generation

Descriptions are not enforced on write in this build — a property without one is accepted and then quietly underfilled at generation time. Section F1 demonstrates it. Treat descriptions as your job, not the validator's.

In [ ]:
SCHEMA = {
    "type": "object",
    "properties": {
        "occupation": {
            "type": "string",
            "description": "The person's current job title, in one short phrase.",
        },
        "location": {
            "type": "string",
            "description": "The city or region the person currently lives in.",
        },
        "interests": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Hobbies and topics they return to, as short lowercase tags.",
        },
        "dietary_restrictions": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Foods the person avoids, and why, if they said.",
        },
        "communication_style": {
            "type": "string",
            "enum": ["concise", "detailed", "casual", "formal"],
            "description": "How this person prefers to be answered.",
        },
        "expertise_level": {
            "type": "string",
            "enum": ["beginner", "intermediate", "advanced"],
            "description": "Their technical depth, judged from how they discuss their work.",
        },
    },
}

settings = client.update_profile_settings(
    enabled=True,
    schema=SCHEMA,
    custom_instructions=(
        "Prefer facts the person stated outright over anything inferred. "
        "Leave a field empty rather than guessing."
    ),
)

# Sorted, because JSONB storage does not preserve the key order you sent.
# Compare a stored schema by SET, never by string or by key order.
stored = settings["entities"]["user"]["schema"]
print("schema fields:", sorted(stored["properties"]))
print("enabled      :", settings["enabled"])
print("capabilities :", settings["capabilities"])

enabled is project-wide; schema and custom_instructions belong to one entity kind and are stored under entities. The SDK takes them flat and nests them for you, so what you write comes back unchanged from get_profile_settings().

Only the arguments you pass are written. To turn the feature off without touching your schema, send enabled alone.

2. The turns

Twelve conversation turns for one user. Nothing about add() changes — profiles are a side effect of the normal pipeline.

Twelve, not five, because generation fires when an entity crosses a 10-message boundary. Below that it waits for a flush window measured in hours, and this notebook would sit there.

In [ ]:
TURNS = [
    ("user", "Hey — I just moved to Berlin for a new job."),
    ("assistant", "Congratulations! What's the new role?"),
    ("user", "Senior data engineer at a logistics company. Mostly Spark and Airflow."),
    ("assistant", "Nice stack. How are you finding the pipelines there?"),
    ("user", "Honestly the DAGs are a mess. I've been rewriting the partitioning to cut shuffle."),
    ("assistant", "That usually pays off fast. Anything blocking you?"),
    ("user", "Just time. Keep it short when you answer me, I skim everything."),
    ("assistant", "Understood — short answers from here."),
    ("user", "Outside work I climb most weekends, and I'm learning German."),
    ("assistant", "Bouldering or ropes?"),
    ("user", "Bouldering. Also — I'm vegetarian, so skip meat in any recipe suggestions."),
    ("assistant", "Noted, vegetarian only."),
]

response = client.add(
    [{"role": r, "content": c} for r, c in TURNS],
    user_id=USER_ID,
)
print(json.dumps(response, indent=2)[:300])

The add is async — it returns an event_id and the memories do not exist yet. Poll GET /v1/event/{event_id}/ until it is SUCCEEDED or FAILED; that, not a sleep, is how you know the add finished. Then let the extracted memories settle.

Under load this can take a minute or more, so the cell says plainly whether it ran out of time rather than printing 0 memories as though that were the answer.

In [ ]:
event_id = response["event_id"]
deadline = time.time() + 300

# 1. The add itself. Terminal status, not a sleep.
event_status = None
while time.time() < deadline:
    event_status = client.client.get(f"/v1/event/{event_id}/").json().get("status")
    if event_status in ("SUCCEEDED", "FAILED"):
        break
    print(f"  add {event_status}")
    time.sleep(5)
print(f"add finished: {event_status}")
assert event_status != "FAILED", "the add itself failed — nothing downstream can work"

# 2. Extraction lands in batches, so the FIRST non-empty page is not the whole set.
# Wait for the count to stop growing instead of breaking on the first result.
memories, stable = [], 0
while time.time() < deadline:
    page = client.get_all(filters={"user_id": USER_ID}, page_size=50)
    found = page.get("results", []) if isinstance(page, dict) else page
    stable = stable + 1 if found and len(found) == len(memories) else 0
    memories = found
    if stable >= 2:  # two identical polls in a row
        break
    print(f"  ... {len(memories)} so far")
    time.sleep(5)

if memories:
    print(f"\n{len(memories)} memories extracted:\n")
    for m in memories:
        print(" \u2022", m.get("memory"))
else:
    # Say so. Reporting '0 memories' as a result hides a busy or broken environment
    # and makes the profile below look like it came from nothing.
    print("\nNO memories yet — extraction is still catching up, or the ingestion")
    print("worker is down. Everything below will report insufficient_data.")

3. Read the profile

Crossing the 10-message boundary should already have queued a generation. Read first — and note that a known user with no profile yet is a 200 with a status, not a 404. That distinction is the whole point of the envelope.

In [ ]:
envelope = client.get_profile(USER_ID)
print(json.dumps(envelope, indent=2))

print("\nstatus vocabulary:")
print("  succeeded          terminal — a generation ran AND the profile has content")
print("  pending            queued or running")
print("  failed             terminal — the last generation did not complete")
print("  not_enabled        feature off, or plan below Pro")
print("  insufficient_data  no content to show: no row yet, queued, or a")
print("                     generation that legitimately found nothing")
print()
print("`succeeded` is decided by the profile BODY, not by generation_count: an")
print("empty extraction still increments the counter, so counting generations")
print("reports 'done' for a profile with nothing in it.")

Force it, rather than waiting

generate_profile() closes the bootstrapping gap: without it a new user has no profile until their tenth message. One entity, a few seconds.

Every call is sent with a fresh Idempotency-Key, so a retry after a dropped connection returns the same job instead of paying twice.

In [ ]:
TERMINAL = {"succeeded", "failed", "not_enabled"}


def wait_for_profile(entity_id, timeout=300, interval=5, since=None):
    """Poll until terminal.

    `since` waits for a generation_count ABOVE that value, which is how you wait
    for an UPDATE rather than accepting the profile you already had.

    `insufficient_data` is NOT terminal by itself — it also covers 'queued', so
    poll through it and give up on the timeout instead.
    """
    deadline = time.time() + timeout
    body = None
    while time.time() < deadline:
        body = client.get_profile(entity_id)
        status = (body.get("status") or "").lower()
        count = body.get("generation_count") or 0
        fresh = count > since if since is not None else True
        if status == "succeeded" and fresh:
            return body
        if status in ("failed", "not_enabled"):
            raise RuntimeError(f"generation stopped: {status}")
        print(f"  ... {status} (generation_count={count})")
        time.sleep(interval)
    raise TimeoutError(f"not ready in {timeout}s: {body}")


print(json.dumps(client.generate_profile(USER_ID), indent=2))
print("\npolling...")

try:
    body = wait_for_profile(USER_ID)
    print("\n=== PROFILE ===")
    print(json.dumps(body["profile"], indent=2))
    print(f"\nstatus={body['status']}  generations={body['generation_count']}  updated={body['updated_at']}")
except TimeoutError as e:
    # Say so plainly and let the rest of the notebook skip, rather than raising
    # a NameError in every cell below and burying the real cause.
    body = None
    print(f"\nNO PROFILE: {e}")
    print("Generation never finished. Usually the ingestion worker is down, or")
    print("this project has no memories for the user yet.")
In [ ]:
# The model must not invent fields outside your schema — the forced tool call is
# what makes that structural rather than a request.
if body is None:
    print("skipped — no profile was generated above")
else:
    extra = set(body["profile"]) - set(SCHEMA["properties"])
    print("fields outside the schema:", extra or "none")

    # A forced JSON-Schema response makes the model emit SOMETHING for every property,
    # so 'I found nothing' arrives as a type default: 0, "", [].
    filled = {k: v for k, v in body["profile"].items() if v not in (None, "", [], {}, 0)}
    print(f"genuinely populated: {len(filled)}/{len(SCHEMA['properties'])} -> {list(filled)}")

4. Now watch it update

Six more turns that contradict and extend what we already know: a promotion, a move, a dropped hobby. A profile is a living document, not an append-only log — the model gets the memories and rewrites the whole thing.

In [ ]:
if body is None:
    print("skipped — no profile was generated above")
else:
    before = body["generation_count"]

    MORE_TURNS = [
        ("user", "Update — I got promoted to staff engineer last week."),
        ("assistant", "Congratulations. Same team?"),
        ("user", "Same company, but I'm relocating to Munich for it."),
        ("assistant", "Big move. How do you feel about it?"),
        ("user", "Good. I've stopped climbing though — knee injury. Picked up cycling instead."),
        ("assistant", "Sorry about the knee. Cycling's kinder on it."),
    ]

    followup = client.add(
        [{"role": r, "content": c} for r, c in MORE_TURNS],
        user_id=USER_ID,
    )

    # Wait for the add to land before triggering: a generation queued before the new
    # memories exist rewrites the profile from the OLD ones and looks like a no-op.
    deadline = time.time() + 300
    while time.time() < deadline:
        status = client.client.get(f"/v1/event/{followup['event_id']}/").json().get("status")
        if status in ("SUCCEEDED", "FAILED"):
            break
        time.sleep(5)
    print("follow-up add:", status)
    time.sleep(15)  # let extraction settle

    print(json.dumps(client.generate_profile(USER_ID), indent=2))
    print(f"\npolling for a NEW generation (count must exceed {before})...")
    updated = wait_for_profile(USER_ID, since=before)
In [ ]:
if body is None:
    print("skipped — no profile was generated above")
else:
    print(f"{'field':<22} {'before':<34} after")
    print("-" * 92)
    for field in SCHEMA["properties"]:
        b = json.dumps(body["profile"].get(field))
        a = json.dumps(updated["profile"].get(field))
        mark = "  " if a == b else "->"
        print(f"{mark} {field:<20} {b[:32]:<34} {a[:32]}")

5. Use it in a prompt

The point of the structure is that it drops straight into a prompt — no list of memories to summarize, no query to write.

In [ ]:
def build_system_prompt(entity_id):
    result = client.get_profile(entity_id)
    if result["status"] != "succeeded":
        # Branch on status, never on an empty profile: a user whose profile is
        # still building is not a user you know nothing about.
        return "You are a helpful assistant."

    p = result["profile"]
    return f"""You are helping {entity_id}.
Occupation: {p.get("occupation", "unknown")}
Location: {p.get("location", "unknown")}
Interests: {", ".join(p.get("interests", [])) or "unknown"}
Dietary restrictions: {", ".join(p.get("dietary_restrictions", [])) or "none stated"}
Preferred style: {p.get("communication_style", "unknown")}

Match their style and do not explain what they already know."""


print(build_system_prompt(USER_ID))

6. Judge a schema before committing to it

sample_profiles() runs your schema against up to 10 real users that have memories.

These are real generations and the results are kept — a dry run would cost exactly the same and leave those users no better off. It is not a free preview.

In [ ]:
# 202, not 200: the sample generations are queued, not finished.
#
# A 409 `already_running` means a sample from an earlier run is still going.
# That is the cooldown working, not an error — reuse that job rather than
# failing the notebook.
try:
    job = client.sample_profiles(limit=3)
    print(json.dumps(job, indent=2)[:400])
    print("\nsampled", job.get("sampled"), "entities:", job.get("entity_ids"))
except Exception as e:
    detail = str(e)
    print("sample refused:", detail[:200])
    running = json.loads(detail).get("error", {}).get("job_id") if detail.startswith("{") else None
    job = {"job_id": running, "status_url": f"/v2/profiles/jobs/{running}/"} if running else None
    print("reusing the running job:", running)

Poll status_url to see how the job went. total is null until enumeration finishes, so format it defensively rather than assuming a number.

In [ ]:
JOB_TERMINAL = {"SUCCEEDED", "FAILED", "COMPLETED", "CANCELLED"}


def wait_for_job(job_response, timeout=300, interval=5):
    """Poll a generation job. Prefer status_url over a bare job id, so a route
    change needs no client update."""
    handle = job_response.get("status_url") or job_response["job_id"]
    deadline = time.time() + timeout
    status = None
    while time.time() < deadline:
        status = client.get_profile_job(handle)["job"]
        total = status.get("total")
        print(
            f"  {status['status']} "
            f"completed={status.get('completed', 0)}/{total if total is not None else '?'} "
            f"succeeded={status.get('succeeded', 0)} "
            f"failed={status.get('failed', 0)} "
            f"skipped={status.get('skipped', 0)}"
        )
        if str(status.get("status", "")).upper() in JOB_TERMINAL:
            return status
        time.sleep(interval)
    return status


if job is None:
    print("no sample job to poll")
else:
    final = wait_for_job(job)

    print("\n--- what the sample produced ---")
    for entity_id in job.get("entity_ids", []):
        got = client.get_profile(entity_id)
        print(f"\n{entity_id} [{got['status']}]")
        print(" ", json.dumps(got["profile"])[:220])

7. Apply a new schema to existing users

A new schema shapes the next generation. Profiles that already exist keep their values until their user is generated again — which happens as that user sends more memories, or when you call generate_profile() for them.

A field you remove stops being maintained: on the next generation, fields your schema no longer defines are pruned. Keep a field for as long as you want its value kept.

Rebuilding a whole project in one call is not available yet. Check capabilities.full_rebuild before offering it in your own UI — the cell below expects to be refused, and that refusal is the feature working.

In [ ]:
caps = client.get_profile_settings().get("capabilities", {})
print("full_rebuild available:", caps.get("full_rebuild"))

if caps.get("full_rebuild"):
    wait_for_job(client.regenerate_profiles())
else:
    try:
        client.regenerate_profiles()
        print("ACCEPTED (unexpected)")
    except Exception as e:
        # 409 not_yet_available. It creates nothing, so nothing was charged.
        print("refused as expected:", str(e)[:200])

Failure scenarios

Everything above is the path that works. These are the ways it says no, and what each one means. Run this section last: F3 deliberately leaves the project switched off for a moment.

About the HTTP error occurred: lines below. The SDK logs every 4xx at ERROR level before raising, so they appear even for the failures these cells deliberately catch. Read the line printed after each one — that is the cell's own verdict. Nothing here is unhandled.

F1. Schemas that get rejected

Rejections happen on write, where you can see and fix them — not silently at generation time, where you would only notice as an empty profile weeks later.

The last case matters for storage: both entity kinds live in one JSONB column alongside the envelope that separates them, so a schema using the envelope's own reserved keys could not be read back unambiguously. It is refused rather than stored.

In [ ]:
BAD_SCHEMAS = [
    ({"type": "object", "properties": {}}, "empty — bills you to extract nothing"),
    ({"type": "array", "items": {"type": "string"}}, "root must be an object"),
    (
        {
            "type": "object",
            "properties": {"tone": {"type": "string", "description": "Preferred tone."}},
            # At the schema ROOT, which is where the envelope's own keys live.
            "_profile_config_version": 1,
            "entities": {"user": {}},
        },
        "reserved settings keys at the schema root",
    ),
]

for bad, why in BAD_SCHEMAS:
    try:
        client.update_profile_settings(schema=bad)
        print(f"ACCEPTED (unexpected): {why}")
    except Exception as e:
        print(f"rejected [{why}]:\n   {str(e)[:160]}\n")

# NOT rejected: a property with no description. The validator allows it and the
# model then has nothing to go on, so the field comes back empty. Descriptions are
# your job, not the validator's.
try:
    client.update_profile_settings(schema={"type": "object", "properties": {"x": {"type": "string"}}})
    print("accepted [no description on 'x'] <- the trap: valid to store, useless to generate")
finally:
    client.update_profile_settings(schema=SCHEMA)  # put the good one back

restored = client.get_profile_settings()["entities"]["user"]["schema"]
assert set(restored["properties"]) == set(SCHEMA["properties"])
print("\nschema restored:", sorted(restored["properties"]))

F2. A user that does not exist

404 means only "no such user". A known user with no profile yet is a 200 carrying insufficient_data, so an ordinary empty state never looks like an error.

In [ ]:
from mem0.exceptions import MemoryNotFoundError

try:
    client.get_profile("user_who_never_existed")
    print("ACCEPTED (unexpected)")
except MemoryNotFoundError as e:
    print("404 as intended:", str(e)[:120])

# ...versus a real user who simply has no profile row yet.
fresh = f"demo_never_profiled_{uuid.uuid4().hex[:6]}"
client.add([{"role": "user", "content": "One passing remark."}], user_id=fresh)
time.sleep(5)
print("known but unprofiled:", client.get_profile(fresh)["status"])

F3. Profiles turned off

enabled is the one project-wide switch. Every generation path then refuses.

Nothing is deleted. Your schema and every profile you already built are kept, so turning it back on resumes rather than restarts.

Note what a read does not do — a profile that already exists keeps reporting succeeded and keeps returning its content. not_enabled is only what you get for a user with no profile yet. Turning the feature off stops new work; it does not hide what has already been built.

In [ ]:
client.update_profile_settings(enabled=False)

print("read (demo user)     :", client.get_profile(USER_ID)["status"])
print("read (never profiled)  :", client.get_profile(fresh)["status"])
try:
    client.generate_profile(USER_ID)
    print("trigger: ACCEPTED (unexpected)")
except Exception as e:
    print("trigger:", str(e)[:160])

back = client.update_profile_settings(enabled=True)  # put it back
print("\nrestored:", back["enabled"])
print("schema survived:", bool(back["entities"]["user"]["schema"]))

8. Cleanup

Removes the demo users. The profile row cascades with the entity.

In [ ]:
for entity_id in (USER_ID, fresh):
    r = client.client.delete(f"/v2/entities/user/{entity_id}/")
    print(entity_id, "->", r.status_code)

# Leave the project's own profile settings alone — other people may share this env.

Cheat sheet

Want Call Cost
configure update_profile_settings(...) free
read get_profile(user_id) free
one user now generate_profile(user_id) 1 LLM call
try a schema sample_profiles(limit=n) ≤10 real generations, kept
everyone regenerate_profiles() not available yet — 409
poll a job get_profile_job(status_url) free

Settings are per entity kind. The stored shape is:

{"enabled": true,
 "entities": {"user": {"schema": {...}, "custom_instructions": "..."}},
 "capabilities": {"full_rebuild": false}}

The SDK takes these flat and nests them for you. Only the fields you pass are written; enabled is the one project-wide switch.

Left alone, generation fires on a 10-message boundary, or after a flush window measured in hours. generate_profile() is how you skip the wait for one user.

Three traps:

  1. insufficient_data is not a terminal verdict — it also covers "queued", so poll through it and give up on a timeout instead.
  2. succeeded is decided by the profile body, not generation_count. An empty extraction still increments the counter.
  3. A forced JSON-Schema response emits something for every property, so "nothing found" arrives as a type default — "", [], 0 — not as a missing key.