Skip to content

Patterns

Adding memory to an existing app

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.

SurrealDB Agent Memory's minimum viable integration is two operations around your existing LLM call:

  1. Before the call - retrieve relevant context and prepend it to the system prompt.

  2. 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 response
import { 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.

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.

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.

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.

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, system
async 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 };
}
PhaseWhat you addWhat changes
1 - Extraction onlyRecord turns after each LLM callMemory accumulates but is not used
2 - Context injectionRetrieve context and inject before each callResponses become memory-aware
3 - Profile injectionInject profile at session startNew sessions start with full user context
4 - Per-user scopingAdd user scope dimensionMemory is isolated per user
5 - DocumentsUpload reference documentsAgents answer from your documents

Each phase is independently deployable and backwards-compatible with the previous one.

Was this page helpful?