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.
|
Two scoping models, one per backend. The Python SDK against Bolt
exposes a rich multi-tenancy surface — first-class |
Goal
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 byadd_preference(user_identifier=…). -
(:Preference)-[:APPLIES_TO]→(:Entity)— written byadd_preference(applies_to=[…]). -
(:Preference)-[:SUPERSEDED_BY]→(:Preference)— written bysupersede_preference. -
Preference.valid_fromandPreference.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.
TypeScript counterpart (NAMS)
The TypeScript SDK targets the hosted NAMS service exclusively, which
doesn’t expose client.users or the universal user_identifier=
primitive. Instead, user identity is carried on the Conversation node:
create the conversation with a userId, and every message added to it
inherits that scope.
import { MemoryClient } from "@neo4j-labs/agent-memory";
const memory = new MemoryClient();
// Sara's conversation — userId tags every message added to this conv
const saraConv = await memory.shortTerm.createConversation({
userId: "sara@omg.com",
metadata: { role: "manager", team: "consulting" },
});
await memory.shortTerm.addMessage(
saraConv.id, "user", "Find me a healthcare team",
);
// Liam's conversation — separate scope, same backend
const liamConv = await memory.shortTerm.createConversation({
userId: "liam@omg.com",
metadata: { role: "consultant" },
});
await memory.shortTerm.addMessage(
liamConv.id, "user", "Find me a fintech team",
);
// List one user's conversations
const sarasConvs = await memory.shortTerm.listConversations({
userId: "sara@omg.com",
});
What ports cleanly
| Python (Bolt) | TypeScript (NAMS) |
|---|---|
|
No direct equivalent. Attach user metadata via |
|
|
|
|
|
|
|
|
|
Not available on NAMS. Best workaround: write a new preference and rely on recency in retrieval, or use the Bolt backend if temporal preferences are required. |
Bridging the two patterns
If you have a mixed Python (Bolt) + TypeScript (NAMS) deployment, the
two SDKs read each other’s entities (entities are shared) but not each
other’s user-scoped preferences (Bolt stores them on :User nodes,
NAMS doesn’t have :User nodes). For systems that need cross-language
per-user preferences, either:
-
Run both SDKs against the same Bolt-backed Neo4j (the TS SDK is NAMS-only, so this means proxying TS → NAMS-on-your-own-Neo4j is not currently supported); or
-
Store per-user data as entities with a deterministic naming convention (e.g.
Preference:sara@omg.com:food-italian) that both SDKs can write and read.
See Cross-Agent Memory Sharing for the broader cross-language architecture.