Multi-Tenant Memory

How to scope conversations, traces, and preferences to a user identity in production. Pre-0.4 every consumer rolled their own :User node and per-user scoping logic; v0.4 makes user identity a first-class concept.

Goal

Multi-tenant scoping: two User nodes each owning separate Conversations and Preferences in a shared Neo4j instance

Two users sharing a Neo4j instance, with reads and writes scoped per user and zero application-level Cypher:

async with MemoryClient(settings) as client:
    await client.users.upsert_user(identifier="sara@omg.com", attributes={"role": "manager"})
    await client.users.upsert_user(identifier="liam@omg.com", attributes={"role": "consultant"})

    # Sara's conversation, scoped via user_identifier=
    await client.short_term.add_message(
        "sara-2026-05-01", "user", "Find me a healthcare team",
        user_identifier="sara@omg.com",
    )

    # Liam's conversation, scoped separately via user_identifier=
    await client.short_term.add_message(
        "liam-2026-05-01", "user", "Find me a fintech team",
        user_identifier="liam@omg.com",
    )

    # Sara's preference, attached to her :User node
    await client.long_term.add_preference(
        "consultants",
        "Senior on healthcare clients",
        user_identifier="sara@omg.com",
        applies_to=[EntityRef(name="Healthcare", type="Industry")],
    )

    # Read back: bootstrap query collapses to one call
    prefs = await client.long_term.get_preferences_for("sara@omg.com")

Steps

1. Upsert the user

Idempotent — safe to call on every request or session start:

user = await client.users.upsert_user(
    identifier="sara@omg.com",
    attributes={"role": "manager", "team": "consulting"},
)

Identifiers are typically email addresses, but anything stable works. The library enforces a unique constraint on User.identifier at the database level.

2. Pass user_identifier= on writes

user_identifier is an optional kwarg on the short-term, long-term, and reasoning memory APIs. When MemorySettings.memory.multi_tenant=True is set, the library enforces that every write includes a user_identifier; omitting it raises a ValueError at the call site.

# Short-term: scope conversations per user
await client.short_term.add_message(
    "sara-2026-05-01", "user", "Find me a healthcare team",
    user_identifier="sara@omg.com",
)

# Long-term: scope preferences per user
await client.long_term.add_preference(
    "consultants",
    "Senior on healthcare",
    user_identifier="sara@omg.com",
    applies_to=[EntityRef(name="Healthcare", type="Industry")],
)

# Reasoning: scope traces per user
trace = await client.reasoning.start_trace(
    "sara-2026-05-01", "consultant search",
    user_identifier="sara@omg.com",
)

3. Read back with get_preferences_for

# All active preferences for Sara
prefs = await client.long_term.get_preferences_for("sara@omg.com")

# Only preferences scoped to Healthcare
prefs = await client.long_term.get_preferences_for(
    "sara@omg.com",
    applies_to=EntityRef(name="Healthcare", type="Industry"),
)

# Include superseded preferences (history)
prefs = await client.long_term.get_preferences_for(
    "sara@omg.com", active_only=False
)

4. Supersede preferences over time

When a user changes their mind, supersede_preference writes (:Preference)-[:SUPERSEDED_BY]→(:Preference) and sets valid_until on the old preference so time-travel queries return the right snapshot:

old = await client.long_term.add_preference(
    "consultants", "Junior", user_identifier="sara@omg.com"
)
new = await client.long_term.add_preference(
    "consultants", "Senior", user_identifier="sara@omg.com"
)
await client.long_term.supersede_preference(old.id, new.id)

# Active only — returns just the new preference
active = await client.long_term.get_preferences_for("sara@omg.com")

# Time-travel — what did Sara prefer at that instant?
from datetime import datetime, timedelta

snapshot = await client.long_term.get_preferences_for(
    "sara@omg.com",
    as_of=datetime.utcnow() - timedelta(days=1),
    active_only=False,
)

Schema

After v0.4 a multi-tenant graph contains:

  • (:User {id, identifier, attributes_json, created_at}) — one per user, identifier-unique.

  • (:User)-[:HAS_PREFERENCE]→(:Preference) — written by add_preference(user_identifier=…​).

  • (:Preference)-[:APPLIES_TO]→(:Entity) — written by add_preference(applies_to=[…​]).

  • (:Preference)-[:SUPERSEDED_BY]→(:Preference) — written by supersede_preference.

  • Preference.valid_from and Preference.valid_until — bi-temporal interval.

Roadmap

The v0.4 surface includes user-scoped conversations, traces, and preferences, including the multi-tenant guardrails described above. Any remaining roadmap work is additive and does not change the recommended scoping model documented on this page.