How do scopes work in Engram?

Short answer: Every memory inherits project scope from the API key; topics can require user_id for hard isolation and custom properties for softer partitions.

Scopes decide who can influence a memory and who can see it later. Project-wide topics share knowledge across users; user-scoped topics never leak across users; property scopes such as conversation_id can be required on write and optional on search so you stay inside one chat or look across all of them. Topics decide what to extract; scopes decide where extraction may live. On memories.add, supply the union of every targeted topic’s requirements. On search, scope parameters act as filters—topics that do not use a property simply ignore it. Property scopes are soft isolation: Engram still separates them on create and update; you choose how wide the query is. Reuse identifiers your product already has. Do not replace Engram scopes with post-filters for user isolation—hard user separation is already enforced.

Scopes in Weaviate Engram decide who can influence a memory and who can see it later. Every memory belongs to a project inherited from your API key. Topics can also require a user_id for hard per-user isolation, and custom properties such as conversation_id for finer partitions. Project-wide topics share knowledge across users. User-scoped topics never leak across users. Property scopes can be required on write and optional on search, so you can stay inside one conversation or look across all of them. This chapter explains those three levels, how add and search differ, how multiple topics combine their requirements, and how Engram enforces isolation so application code does not have to invent its own tenancy layer.

Topics decide what to extract. Scopes decide where that extraction is allowed to live. Get the scope wrong and even perfect topic descriptions cannot keep customer data apart.

What does project scope mean in practice?

After topics are configured, the first isolation question is which project owns the memory. Engram answers that from the API key. You never pass a project id on add or search. Every memory created with that key lands in exactly one project. That outer boundary is automatic.

Inside the project, topics that are not user-scoped are project-wide. They are useful for procedural memory. An agent can learn how to escalate refunds or how to reset a printer, and every user session can reuse that knowledge. No user_id is required for those topics. That shared pool is intentional. It is how continual learning stays separate from personalization without a second database.

Project scope is also why keys matter operationally. Rotate a key and you still point at the same project if the key belongs to it. Point a different project’s key at the wrong environment and you will write to the wrong memory world. Isolation starts with which key your service is holding.

How does user scope isolate memories?

Knowing the project is fixed, the next question is how one user’s facts stay away from another’s. User-scoped topics require a user_id on both store and search. Engram enforces hard isolation between users. A search for Alice never returns Bob’s memories, even when the query text would match. That isolation is enforced in storage, not as a soft filter you hope the app remembers to apply.

Weaviate’s multi-tenancy underpins that guarantee for user data. You do not maintain a parallel ACL table for ordinary personalization topics. Pass the same user_id you used when writing, and retrieval stays inside that tenant. Forget the id on a user-scoped topic and the request is rejected rather than falling open.

Choose user scope for preferences, personal details, and anything that must never cross account boundaries. Keep playbooks and shared procedures on project-wide topics when every operator should see them. Mixing both kinds of topics in one group is allowed. Scoping is configured per topic, not as a single switch on the whole group.

When do custom property scopes help?

User isolation answers whose memory it is. Many products also need which conversation, session, or workspace it belongs to. Custom property scopes cover that. A topic can declare extra keys such as conversation_id, session_id, or tenant_id. On store, every required property must be present. On search, including a property narrows results. Omitting it searches across all values for that key.

That asymmetry is deliberate. Writes must be fully placed so the pipeline knows which soft partition to update. Reads can widen or narrow. You can ask for one chat’s summary with conversation_id set, or omit it to gather a user’s memories across every chat. Property scopes are soft isolation in the blog’s sense. Engram still separates them when creating and updating. You choose how wide the query is.

Property keys are arbitrary as long as they match the topic configuration. Name them after identifiers your product already has. Do not invent a second identity system just for Engram. Reuse the conversation id your chat service already issues.

How do add and search treat scope parameters differently?

With three levels in view, the practical API question is what you must pass on each call. For client.memories.add, supply the union of every targeted topic’s requirements. If any topic needs user_id, pass it. If any needs properties.conversation_id, pass that too. Missing a required scope rejects the request. Content type does not change the rule. String, conversation, and pre-extracted inputs all follow the topic’s scoping.

For client.memories.search, scope parameters act as filters. You pass the ones you want to narrow by. Topics that do not use a property ignore it. When you search several topics at once, you can also override properties per topic with the Topic helper, including clearing an inherited filter with None for one topic only.

Here is a cheese-cave tasting desk that keeps cellar operator notes user-scoped and tasting-session summaries property-scoped by batch id.

import os
from engram import EngramClient, HybridRetrieval, Topic

client = EngramClient(api_key=os.environ["ENGRAM_API_KEY"])

affineur = "aging-cave-b"
batch = "batch-comté-184"

run = client.memories.add(
    [
        {"role": "user", "content": "Cave B prefers cooler rind washes on Fridays."},
        {"role": "assistant", "content": "Noted for your shifts."},
        {"role": "user", "content": "Batch Comté 184 showed early ammonia on shelf 2. Hold sale."},
    ],
    user_id=affineur,
    group="default",
    properties={"conversation_id": batch},
)
client.runs.wait(run.run_id)

one_batch = client.memories.search(
    query="ammonia on shelf and hold sale",
    user_id=affineur,
    group="default",
    properties={"conversation_id": batch},
    topics=["user_facts", "conversation_summary"],
    retrieval_config=HybridRetrieval(limit=5),
)

across = client.memories.search(
    query="Friday rind wash preference",
    user_id=affineur,
    group="default",
    topics=[
        "user_facts",
        Topic(name="conversation_summary", properties={"conversation_id": None}),
    ],
    retrieval_config=HybridRetrieval(limit=5),
)

assert any("184" in m.content or "ammonia" in m.content.lower() for m in one_batch)
assert any("Friday" in m.content or "rind" in m.content.lower() for m in across)

The first search stays inside batch Comté 184. The second clears the conversation filter on the summary topic so broader recall is possible while user_facts still stay tied to the affineur. Engram is doing the isolation work. Your code only names the scopes.

How should you choose scopes when designing topics?

After the API pattern is clear, design reduces to matching identifiers you already trust. If a fact is personal, make the topic user-scoped. If a fact is a shared procedure, leave it project-wide. If a fact is about one thread of work, add a property key your product already generates. Avoid stacking properties you will never filter on. Each required key becomes another value every writer must supply.

When one request targets multiple topics, plan for the union of requirements up front. Support agents often search user_facts and conversation_summary together. That means writers that feed the summary topic must send conversation_id, and readers that want both slices should pass user and conversation filters together. Document that contract next to the topic names in your app config.

Do not try to replace Engram scopes with post-filters in your own code for user isolation. Hard user separation is already enforced. Soft property filters are already available on search. Use them. Keep application logic focused on which scope values the current request represents.

Our next chapter, What are bounded topics and single-object-per-scope guarantees?, builds on these isolation keys. You will see how a topic can promise at most one memory per scope, and why that shape fits profiles and running summaries.