Most teams do not rebuild their application to add memory. This guide covers the minimal integration path: intercepting existing LLM calls to extract memory, injecting context before those calls, and gradually expanding the integration without disrupting what already works.
The minimal integration
SurrealDB Agent Memory's minimum viable integration is two operations around your existing LLM call:
Before the call - retrieve relevant context and prepend it to the system prompt.
After the call - record the user message and assistant response as turns.
No sessions are required for the first pass. You keep your existing data model and LLM client; SurrealDB Agent Memory sits beside them as the memory layer.
from surrealdb.memory import AsyncMemory
memory = AsyncMemory(context="my-app", api_key="sp-...")
CONTEXT_ID = "my-app"
DEFAULT_SCOPE = ["org/my-org"] # Start with a single scope
async def call_llm_with_memory(user_message: str,
session_id: str | None = None) -> str:
# 1. Create a session on first call; afterwards carry the id forward.
# There is no "open" call - a session is addressed by its id.
if session_id is None:
session = await memory.sessions.create(scopes=DEFAULT_SCOPE)
session_id = session.id
# Session context is one string, ready for a prompt
ctx = await memory.sessions.context(session_id, user_message)
# 2. Inject into your existing system prompt
system = your_existing_system_prompt()
if ctx.context:
system = f"{system}\n\n## Relevant context\n{ctx.context}"
# 3. Your existing LLM call - unchanged
response = your_existing_llm_call(system=system, user=user_message)
# 4. Record the exchange
await memory.remember(user_message, session_id=session_id, role="user")
await memory.remember(response, session_id=session_id, role="assistant")
return responseimport { AgentMemory } from "@surrealdb/memory";
const memory = new AgentMemory({ context: "my-app", apiKey: "sp-..." });
const DEFAULT_SCOPE = ["org/my-org"];
async function callLlmWithMemory(
userMessage: string,
sessionId?: string,
): Promise<{ response: string; sessionId: string }> {
const id = sessionId
?? (await memory.sessions.create({ scopes: DEFAULT_SCOPE })).id;
// The JavaScript SDK reads session context through a Session object.
// With only the id to hand, read the Context's memory through the same scope.
const ctx = await memory.context(userMessage, { lens: DEFAULT_SCOPE });
let system = yourExistingSystemPrompt();
if (ctx.context) {
system = `${system}\n\n## Relevant context\n${ctx.context}`;
}
const response = await yourExistingLlmCall({ system, user: userMessage });
await memory.remember(userMessage, { sessionId: id, role: "user" });
await memory.remember(response, { sessionId: id, role: "assistant" });
return { response, sessionId: id };
}The key principle: do not change what your LLM receives if there is no relevant memory. The if ctx.context guard ensures that when SurrealDB Agent Memory has nothing useful to add, the call is identical to the original.
Intercepting existing LLM calls
If your application already has a wrapper around LLM calls, add memory extraction at that layer. This avoids scattering SurrealDB Agent Memory calls throughout your codebase.
# Before: a simple LLM wrapper
async def llm(system: str, user: str) -> str:
return await openai_client.chat(system=system, user=user)
# After: the same wrapper with memory
async def llm(system: str, user: str,
session_id: str | None = None) -> str:
if session_id:
ctx = await memory.sessions.context(session_id, user)
if ctx.context:
system = f"{system}\n\n{ctx.context}"
response = await openai_client.chat(system=system, user=user)
if session_id:
await memory.remember(user, session_id=session_id, role="user")
await memory.remember(response, session_id=session_id, role="assistant")
return response// Before
async function llm(system: string, user: string): Promise<string> {
return openaiClient.chat({ system, user });
}
// After
async function llm(system: string, user: string,
sessionId?: string): Promise<string> {
let enrichedSystem = system;
if (sessionId) {
const ctx = await memory.context(user, { lens: DEFAULT_SCOPE });
if (ctx.context) enrichedSystem = `${system}\n\n${ctx.context}`;
}
const response = await openaiClient.chat({ system: enrichedSystem, user });
if (sessionId) {
await memory.remember(user, { sessionId, role: "user" });
await memory.remember(response, { sessionId, role: "assistant" });
}
return response;
}Making session_id optional means the change is backwards-compatible - all existing call sites continue to work without passing a session.
Starting with a single scope
Do not attempt multi-user scoping on day one. Start with a single organisational scope and confirm the extraction pipeline is working correctly before splitting by user.
# Phase 1: single scope, all conversations share it
DEFAULT_SCOPE = ["org/my-app"]
# Phase 2 (later): add user dimension
def scope_for_user(user_id: str) -> list[str]:
return [f"org/my-app/user/{user_id}"]The profile and context endpoints are scope-matched, and a read never reaches an ancestor scope. A reader limited to ["org/my-app/user/alice"] sees memory stored at that path and below it, but not memory stored at the broader ["org/my-app"] path. A reader whose grants cover org/my-app/* sees both.
Expanding to multi-user
Once single-scope extraction is verified, add the user dimension. The only change is in how you create sessions:
# Before
session = await memory.sessions.create(scopes=["org/my-app"])
# After
session = await memory.sessions.create(scopes=[f"org/my-app/user/{user_id}"])New memory is stored under the user scope. Existing memory under the org-only scope stays where it is, and is readable only by keys whose grants cover org/my-app itself, such as a key granted org/my-app/*. A key limited to one user's path does not see it, because the org path is an ancestor of the user path. If per-user keys need that earlier memory, replay it under each user's scope.
Profile injection at session start
Once you have per-user memory accumulating, add profile injection at the start of each new conversation:
async def start_session(user_id: str) -> tuple[str, str]:
session = await memory.sessions.create(
scopes=[f"org/my-app/user/{user_id}"],
)
profile = await memory.profile()
system = your_existing_system_prompt()
profile_block = format_profile(profile) # see Profiles: injecting into prompts
if profile_block:
system = f"{system}\n\n## About this user\n{profile_block}"
return session.id, systemasync function startSession(userId: string): Promise<{ sessionId: string; system: string }> {
const [session, profile] = await Promise.all([
memory.sessions.create({ scopes: [`org/my-app/user/${userId}`] }),
memory.profile(),
]);
let system = yourExistingSystemPrompt();
const profileBlock = formatProfile(profile); // see Profiles: injecting into prompts
if (profileBlock) {
system = `${system}\n\n## About this user\n${profileBlock}`;
}
return { sessionId: session.id, system };
}Migration path summary
| Phase | What you add | What changes |
|---|---|---|
| 1 - Extraction only | Record turns after each LLM call | Memory accumulates but is not used |
| 2 - Context injection | Retrieve context and inject before each call | Responses become memory-aware |
| 3 - Profile injection | Inject profile at session start | New sessions start with full user context |
| 4 - Per-user scoping | Add user scope dimension | Memory is isolated per user |
| 5 - Documents | Upload reference documents | Agents answer from your documents |
Each phase is independently deployable and backwards-compatible with the previous one.