Associate memory with users and select scoped reads

Available on NAMS: Yes, with differences. NAMS supports this kind of memory, but not every call on this page: some steps run server-side, some calls take different arguments or return different shapes, and some are Bolt-only. Check the backend capabilities reference before you adapt a Bolt procedure.

To associate Bolt conversation writes and preferences with a user, pass the identifier only to supported operations and use an explicitly scoped read. This guide does not establish user authentication or database access control.

Prerequisites

  • A connected Python MemoryClient using Bolt and a database intended for this application.

  • An application-authenticated user identifier. Do not accept another user’s identifier directly from an untrusted request.

  • The current scope limitations, especially the ignored session_id filter in Bolt semantic message search. Use separate authorized databases where independent tenant isolation is required.

1. Associate supported writes with a user

Two :User nodes, each linked by a HAS_CONVERSATION relationship to its own Conversation, with that user’s messages and preferences shown alongside, all held in one shared Neo4j instance. A caption notes that user_identifier= scopes only the operations that accept it
Figure 1. Two :User nodes, each with its own conversation, messages, and preferences in one shared Neo4j instance

The following fragments run inside async with MemoryClient(settings) as client:. They use synthetic identities and do not require a model call for the demonstrated writes.

await client.users.upsert_user(identifier="sara-demo")
await client.users.upsert_user(identifier="liam-demo")

await client.short_term.add_message(
    "sara-demo-session", "user", "Please use concise answers.",
    user_identifier="sara-demo",
    extract_entities=False,
    generate_embedding=False,
)
pref = await client.long_term.add_preference(
    "communication", "Use concise answers",
    user_identifier="sara-demo",
    generate_embedding=False,
)

user_identifier is accepted by particular message, preference and trace write methods. It is not accepted by every API; for example, Bolt add_entity does not take this argument. memory.multi_tenant=True requires it on specific write paths but does not make all reads user-scoped.

2. Read preferences through the user relationship

sara_preferences = await client.long_term.get_preferences_for("sara-demo")
liam_preferences = await client.long_term.get_preferences_for("liam-demo")
assert any(item.id == pref.id for item in sara_preferences)
assert all(item.id != pref.id for item in liam_preferences)
print("The new preference belongs to sara-demo.")

These writes disable embedding-based preference deduplication so the fixture creates a distinct preference. Default duplicate lookup can reuse a global preference node and link multiple users to it; superseding that shared node affects all linked users. Review ownership before revising existing data.

get_preferences_for selects preferences connected to the requested user. search_preferences instead performs semantic search and does not automatically use that user. Entity search and trace similarity search also have their own scopes.

3. Read the intended conversation

conversation = await client.short_term.get_conversation("sara-demo-session")
assert conversation is not None
assert any(message.content == "Please use concise answers." for message in conversation.messages)
print("Read the intended conversation.")

Authorize the session before this call. Do not replace it with search_messages(session_id=…​) and infer equivalent isolation: the current Bolt search does not apply that filter. Combined get_context is also not a tenancy boundary.

4. Supersede a preference explicitly

replacement = await client.long_term.add_preference(
    "communication", "Use detailed answers",
    user_identifier="sara-demo",
    generate_embedding=False,
)
await client.long_term.supersede_preference(pref.id, replacement.id)
active = await client.long_term.get_preferences_for("sara-demo")
assert any(item.id == replacement.id for item in active)
assert all(item.id != pref.id for item in active)
print("Only the replacement is active.")

For applies_to, historical as_of reads, validity fields and constraints, see the long-term reference.

Manage user records

client.users (UserMemory) manages the :User nodes that user_identifier= links to. Supported write methods MERGE the :User node by identifier, so an explicit upsert is needed only to store attributes or to create the user before its first write. A user created implicitly by a write takes its identifier as its id; upsert_user assigns a UUID when it creates the node.

user = await client.users.upsert_user(
    identifier="sara-demo",
    attributes={"role": "manager", "team": "consulting"},
)
same_user = await client.users.get_user("sara-demo")  # None when absent
assert same_user is not None and same_user.id == user.id
recent_users = await client.users.list_users(limit=100)  # newest first

upsert_user(*, identifier, attributes=None) is idempotent. Calling it again with attributes replaces the stored attributes; calling it without them keeps the existing attributes. The schema setup creates a unique constraint on User.identifier, so each identifier maps to one :User node.

User-scoped writes produce this graph shape:

  • (:User {id, identifier, attributes_json, created_at}): one node per identifier.

  • (:User)-[:HAS_CONVERSATION]→(:Conversation): written by message and conversation writes that pass user_identifier=.

  • (:User)-[:HAS_TRACE]→(:ReasoningTrace): written by start_trace(user_identifier=…​).

  • (: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, which also sets valid_until on the old preference.

  • Preference.valid_from and Preference.valid_until: the validity interval that get_preferences_for(as_of=…​) reads.

TypeScript counterpart (NAMS)

NAMS separates workspaces through authentication. Within one workspace, explicitly create and retain a conversation ID; userId alone does not resume it or authorize the caller.

import { MemoryClient } from "@neo4j-labs/agent-memory";

const memory = new MemoryClient(); // MEMORY_API_KEY selects the workspace
const conversation = await memory.shortTerm.createConversation({ userId: "sara-demo" });
await memory.shortTerm.addMessage(conversation.id, "user", "Please use concise answers.");
const saved = await memory.shortTerm.getConversation(conversation.id);
console.log(saved);

NAMS does not expose the Bolt preference API. Use the conversation recall lesson for persisting and reusing an ID across processes, and authentication for workspace selection.

Verification

In a test database/workspace, use two identities and confirm each explicitly scoped read returns only its intended fixture. Test authorization separately. For any claimed semantic-search filtering, use overlapping vectors and highly ranked foreign-session rows, with and without metadata filters; unrelated text alone can hide a missing filter. See capabilities and limitations before extending this pattern to other operations.