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 :User nodes (client.users.upsert_user(…​)), a universal user_identifier= kwarg on writes, and bi-temporal preference supersedence. NAMS (used by both Python and TypeScript over HTTP) exposes a simpler model: conversation-level user scoping via userId on createConversation. This page covers the Bolt-side primitives in detail; the TypeScript / NAMS counterpart is at the bottom.

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.

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)

client.users.upsert_user(identifier, attributes)

No direct equivalent. Attach user metadata via createConversation({ userId, metadata: {…​} }).

client.short_term.add_message(…​, user_identifier=)

memory.shortTerm.addMessage(conversationId, role, content) — scoping is on the conversation, not the message.

client.long_term.add_preference(…​, user_identifier=)

memory.longTerm.addPreference(category, preference) — preferences are workspace-wide on NAMS, not per-user. To per-user-scope, attach the preference to a user-scoped entity instead.

client.long_term.get_preferences_for(identifier, applies_to=, as_of=)

memory.longTerm.searchPreferences(query, { category }) — no built-in as_of time-travel; query Cypher with client.query.cypher(…​) if you need it.

client.reasoning.start_trace(…​, user_identifier=)

memory.reasoning.startTrace(sessionId, task) — pass sessionId = conversationId from the user-scoped conversation to inherit the scope.

supersede_preference(old, new) with bi-temporal valid_until

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.