Add persistent conversation context to a chatbot

We will store a shopper’s first conversation, stop the process, and answer a new question in a second process using the earlier conversation, an explicit budget preference, and a fictional product. The program passes that retrieved context to a chat model and records the product lookup as a trace.

This lesson uses neo4j-agent-memory 0.7.0 from PyPI, the complete programs on this page, and a dedicated Neo4j AuraDB instance over Bolt. Offline contract tests exercise their assembly and control flow; provider and database success still need to be observed in your environment.

Before you begin

  • Python 3.10 or newer and a POSIX shell such as Bash or Zsh.

  • An OpenAI API key with access to text-embedding-3-small.

  • A Neo4j Aura account with capacity for a dedicated AuraDB Free instance.

The embedding calls send the supplied text to OpenAI. This exercise uses fictional data. Neo4j Agent Memory is an experimental Neo4j Labs project; see backend capabilities before adapting the example to NAMS.

You also need an OpenAI chat-model ID available to your account. This lesson sends retrieved conversation text and preferences to that model. The budget is explicitly seeded; automatic preference learning is outside this exercise.

1. Install the lesson dependencies

Create a local folder and virtual environment for the examples on this page. The commands reuse an existing environment without changing its files:

mkdir -p ~/agent-memory-tutorials
cd ~/agent-memory-tutorials
if [ -e .venv ]; then
  printf '%s\n' 'Using the existing virtual environment.'
else
  python3 -m venv .venv
fi
source .venv/bin/activate

Expected: ~/agent-memory-tutorials is your working directory and its virtual environment is active. Install the published SDK with the command below.

Each complete code block labelled Save as names a file to create in this folder using your editor. Copy the entire block, including imports and the entry point. Expand each helper disclosure and use Copy code to copy its full source. Keep all files together so their imports resolve.

When continuing from another tutorial or guide, retain the existing environment, configuration, session files, and .tutorial-state/. Reuse unchanged helper files; compare an existing file before replacing it, and finish any pending cleanup or recovery before changing the code that owns its state.

python -m pip install 'neo4j-agent-memory[openai]==0.7.0'
python -c "from importlib.metadata import version; \
import neo4j_agent_memory; \
assert version('neo4j-agent-memory') == '0.7.0'; \
print('SDK 0.7.0 import verified')"

Expected: SDK 0.7.0 import verified. Keep agent-memory-tutorials as your working directory for every command below.

Create and check the local files

Create each file below in ~/agent-memory-tutorials. All complete sources follow this manifest. On a first visit every file is new; when returning, reuse unchanged helpers and compare their contents before replacing them.

File Purpose Returning reader

aura_connection.py

Read explicit Aura credentials; the database defaults to neo4j.

Reuse unchanged

wait_for_tutorial_neo4j.py

Probe the selected database and close the driver.

Reuse unchanged

core_memory_settings.py

Construct the explicit Bolt and OpenAI embedding settings.

Reuse unchanged

conversation_memory.py

Seed the first conversation, then answer using its stored context. This is the lesson entry point.

New for this lesson; retain it with unfinished state

The manifest above names this lesson’s entry point and its helpers. Save every file it lists, then run the offline assembly checks that follow the sources before the lesson commands.

Open the helper below and save its complete source as aura_connection.py in ~/agent-memory-tutorials.

Show aura_connection.py
Save as aura_connection.py
"""Read the Aura connection exported by the documentation setup commands."""

import os


class AuraConfigurationError(ValueError):
    """Missing or invalid tutorial settings, with no credential values in errors."""


def aura_config():
    required = ("NEO4J_URI", "NEO4J_USERNAME", "NEO4J_PASSWORD")
    missing = [name for name in required if not os.environ.get(name, "").strip()]
    if missing:
        raise AuraConfigurationError("Export the Aura connection settings: " + ", ".join(missing))
    uri = os.environ["NEO4J_URI"]
    if not uri.startswith("neo4j+s://"):
        raise AuraConfigurationError("NEO4J_URI must use the Aura neo4j+s:// connection scheme")
    database = os.environ.get("NEO4J_DATABASE", "neo4j")
    if not database.strip():
        raise AuraConfigurationError("NEO4J_DATABASE must not be empty")
    return {
        "uri": uri,
        "username": os.environ["NEO4J_USERNAME"],
        "password": os.environ["NEO4J_PASSWORD"],
        "database": database,
    }

Open the helper below and save its complete source as wait_for_tutorial_neo4j.py in ~/agent-memory-tutorials.

Show wait_for_tutorial_neo4j.py
Save as wait_for_tutorial_neo4j.py
"""Check the exported Aura connection, waiting at most three minutes."""

import asyncio
import sys

from aura_connection import AuraConfigurationError, aura_config
from neo4j import AsyncGraphDatabase
from neo4j.exceptions import DriverError, Neo4jError, ServiceUnavailable, SessionExpired


async def wait_until_ready(timeout=180):
    if timeout <= 0:
        raise ValueError("Readiness timeout must be positive")
    config = aura_config()

    async def probe(driver):
        while True:
            try:
                await driver.verify_connectivity()
                records, _, _ = await driver.execute_query(
                    "RETURN 1 AS ready", database_=config["database"], routing_="r"
                )
                if len(records) != 1 or records[0]["ready"] != 1:
                    raise RuntimeError("Unexpected readiness query result")
                return
            except (ServiceUnavailable, SessionExpired):
                await asyncio.sleep(2)

    async with AsyncGraphDatabase.driver(
        config["uri"],
        auth=(config["username"], config["password"]),
        connection_timeout=min(10, timeout),
        connection_acquisition_timeout=min(10, timeout),
    ) as driver:
        await asyncio.wait_for(probe(driver), timeout=timeout)


def main():
    try:
        asyncio.run(wait_until_ready())
    except AuraConfigurationError as error:
        print(str(error), file=sys.stderr)
        raise SystemExit(1) from None
    except (asyncio.TimeoutError, DriverError, Neo4jError) as error:
        # Driver exceptions can include connection details. Report the category
        # and next action without echoing credentials or a raw server message.
        print(
            f"Neo4j Aura readiness failed ({type(error).__name__}). "
            "Check that the instance is Running, the exported NEO4J_* values "
            "match its credentials, and your network allows the connection.",
            file=sys.stderr,
        )
        raise SystemExit(1) from None
    print("Verified: Neo4j Aura answered the readiness query")


if __name__ == "__main__":
    main()
Save as core_memory_settings.py
"""Shared settings for the three Aura-backed memory tutorials."""

from aura_connection import aura_config

from neo4j_agent_memory import MemorySettings


def settings():
    return MemorySettings(
        backend="bolt",
        neo4j=aura_config(),
        embedding="openai/text-embedding-3-small",
        llm=None,
        extraction={"extractor_type": "none"},
        resolution={"strategy": "none"},
        geocoding={"enabled": False},
        enrichment={"enabled": False},
    )
Save as conversation_memory.py
"""Pass persisted history and explicit preferences to an actual chat completion."""

import argparse
import asyncio
import os

from core_memory_settings import settings

from neo4j_agent_memory import MemoryClient

USER = "docs-shopper"
OLD_SESSION = "docs-shopping-first"
NEW_SESSION = "docs-shopping-return"
PRODUCT = "Trail Starter: fictional walking shoe; price USD 45; wide fit."


async def seed(client):
    if (await client.short_term.get_conversation(OLD_SESSION)).messages:
        raise RuntimeError("Already seeded; run resume or use a fresh tutorial database")
    await client.short_term.add_message(
        OLD_SESSION,
        "user",
        "I need wide walking shoes for a city trip.",
        user_identifier=USER,
        extract_entities=False,
    )
    await client.short_term.add_message(
        OLD_SESSION,
        "assistant",
        "I will look for wide walking shoes.",
        user_identifier=USER,
        extract_entities=False,
    )
    await client.long_term.add_preference(
        "budget",
        "Spend at most USD 60 on walking shoes",
        user_identifier=USER,
        context="Explicit preference supplied by this exercise",
    )
    await client.long_term.add_entity(
        "Trail Starter",
        "OBJECT",
        subtype="PRODUCT",
        description=PRODUCT,
        resolve=False,
        deduplicate=False,
    )
    print("Stored: first conversation, explicit shopper preference, fictional product")


async def resume(client, llm, model):
    history = await client.short_term.get_conversation(OLD_SESSION)
    preferences = await client.long_term.get_preferences_for(USER)
    if len(history.messages) != 2 or not preferences:
        raise RuntimeError("Run seed before resume")
    prompt = "What would you recommend for the trip I mentioned?"
    user_message = await client.short_term.add_message(
        NEW_SESSION, "user", prompt, user_identifier=USER, extract_entities=False
    )
    trace = await client.reasoning.start_trace(
        NEW_SESSION,
        "Recommend a product using saved shopper context",
        triggered_by_message_id=user_message.id,
        user_identifier=USER,
    )
    try:
        step = await client.reasoning.add_step(trace.id, action="Read the tutorial product")
        products = await client.query.cypher(
            "MATCH (e:Entity {name: $name, type: 'OBJECT', subtype: 'PRODUCT'}) "
            "RETURN e.description AS description",
            {"name": "Trail Starter"},
        )
        await client.reasoning.record_tool_call(
            step.id, "lookup_product", {"name": "Trail Starter"}, result=products
        )
        if not products:
            raise RuntimeError("Seeded product was not found")
        system = (
            "You are a shopping assistant. Use only the supplied fictional catalog. "
            "Do not invent stock or prices.\nSaved preferences:\n"
            + "\n".join(p.preference for p in preferences)
            + "\nCatalog:\n"
            + "\n".join(p["description"] for p in products)
        )
        messages = [{"role": "system", "content": system}]
        messages.extend({"role": m.role.value, "content": m.content} for m in history.messages)
        messages.append({"role": "user", "content": prompt})
        print(
            f"Retrieved: {len(history.messages)} prior messages and {len(preferences)} preferences"
        )
        response = await llm.chat.completions.create(model=model, messages=messages)
        answer = response.choices[0].message.content
        if not answer:
            raise RuntimeError("The model returned no text")
        saved = await client.short_term.add_message(
            NEW_SESSION, "assistant", answer, user_identifier=USER, extract_entities=False
        )
        await client.reasoning.complete_trace(
            trace.id, outcome="Saved a response using retrieved shopper context", success=True
        )
    except Exception as error:
        await client.reasoning.complete_trace(
            trace.id, outcome=f"Response failed: {type(error).__name__}", success=False
        )
        raise
    readback = await client.short_term.get_conversation(NEW_SESSION)
    assert any(message.id == saved.id for message in readback.messages)
    recorded = await client.reasoning.get_trace_with_steps(trace.id)
    assert recorded is not None and recorded.steps
    assert any(step.tool_calls for step in recorded.steps)
    print("Verified: new response and product-lookup trace read back")
    print(answer)
    return messages


async def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("command", choices=["seed", "resume"])
    args = parser.parse_args()
    async with MemoryClient(settings()) as client:
        if args.command == "seed":
            await seed(client)
        else:
            # Imported here: only this branch calls the chat model. Both branches
            # still need the openai extra, which the settings use for embeddings.
            from openai import AsyncOpenAI

            async with AsyncOpenAI() as llm:
                await resume(client, llm, os.environ["OPENAI_MODEL"])


if __name__ == "__main__":
    asyncio.run(main())

Check the copied files before exporting credentials or making a service request:

python -m py_compile aura_connection.py wait_for_tutorial_neo4j.py core_memory_settings.py conversation_memory.py
python -c "import aura_connection, wait_for_tutorial_neo4j, core_memory_settings, conversation_memory; assert all(callable(f) for f in [aura_connection.aura_config, wait_for_tutorial_neo4j.wait_until_ready, core_memory_settings.settings, conversation_memory.main]); print('Lesson imports verified')"
python conversation_memory.py --help

Expected: compilation exits without errors, then Lesson imports verified and the command help. These checks import the local files without calling their entry points, downloading models, or contacting Aura/providers. Compilation alone does not check imports. If a file or function is missing, recopy its entire source and repeat the checks; leave existing session/state files intact. Provider/framework calls are checked when the lesson runs.

2. Create and connect to the Aura database

This starts a new lesson with a new empty database. To return to an unfinished lesson, use Resume this lesson later instead.

  1. Sign in to the Neo4j Aura console and create an AuraDB Free instance named agent-memory-tutorial. Use an empty instance dedicated to this lesson; do not load a sample dataset.

  2. Download the generated credentials and keep them outside the tutorial folder. Wait until the instance shows Running.

  3. Copy its connection URI, username, password and database name into the exports below. Keep the neo4j+s:// scheme supplied by Aura.

Aura permits one Free instance per account. This lesson needs that slot for a dedicated tutorial instance; do not delete an existing database containing other work to make room. See Aura instance creation for account and tier requirements.

The files from the preceding assembly checkpoint read these exports and verify the selected database.

export NEO4J_URI='neo4j+s://<instance-id>.databases.neo4j.io'
export NEO4J_USERNAME='neo4j'
export NEO4J_PASSWORD='replace-with-the-generated-password'
export NEO4J_DATABASE='neo4j'
python wait_for_tutorial_neo4j.py

Expected: Verified: Neo4j Aura answered the readiness query. The helper checks connectivity and executes RETURN 1 in the selected database, retrying temporary connection failures for up to three minutes. Authentication failures stop immediately. If the check fails, confirm the instance is Running and recopy its connection settings.

The examples read these exported variables through aura_connection.py. They require the Aura URI, username and password and never fall back to a local instance. When NEO4J_DATABASE is unset, they use the neo4j database, which is the Aura default. Keep this shell and virtual environment active for the remaining commands. See Aura connection instructions for help locating the settings.

In the Aura console, open Query, select this instance and database in the connection bar, and connect using the same credentials. Run:

MATCH (n) RETURN count(n) AS node_count

Expected: 0. This confirms that the lesson starts with an empty database. The Python SDK still uses backend="bolt": Aura hosts Neo4j and accepts encrypted Bolt connections.

3. Configure the provider and inspect the shared settings

Before filling in OPENAI_MODEL, open the OpenAI model catalog, select a text model, and check its model page for Chat Completions support. Copy its API model ID, not its display name. In the API project that owns your key, confirm the model is permitted and that usage/billing limits allow this exercise. A ChatGPT subscription or a key-presence check does not establish that project’s API access.

The Models retrieve endpoint provides an optional authenticated metadata check for the chosen ID before lesson writes. That is a real provider request but does not generate a completion or embed text; finding metadata does not prove inference permission or available quota. The lesson’s embedding/chat requests are separate operations and can incur usage charges.

Resolve missing or blank exports locally. If a provider request fails, distinguish authentication failure (check the project/key), model not found or access denied (check the exact ID and project permissions), and quota/rate limits (check billing/limits or retry guidance). Do not keep rerunning a state-changing lesson to test access; follow its inspection/reset instructions after a partial write.

export OPENAI_API_KEY="replace-with-your-key"
export OPENAI_MODEL="replace-with-your-accessible-chat-model-id"
python -c "import os; \
assert os.environ['OPENAI_API_KEY'].strip(); \
model = os.environ['OPENAI_MODEL']; \
assert model.strip() and not model.startswith('replace-with-'); \
print('Provider variables present')"

Expected: Provider variables present. This checks configuration presence; the requests in the program verify access. Set OPENAI_MODEL to a chat model available to your account. Do not put the API key in the program.

The core_memory_settings.py file copied above is imported by the lesson program.

The helper uses the exported Aura connection, explicitly selects the Bolt backend, disables automatic extraction and enrichment, and selects the embedding adapter. The complete program imports this helper from the tutorial folder.

4. Read the complete chatbot program

Read conversation_memory.py from step 1: main() opens the public MemoryClient; seed() calls message/entity/preference APIs; resume() uses get_conversation() and get_preferences_for() to build a model request. The product query and reasoning calls record an application action. The command parser and exact-result assertions are tutorial scaffolding. Each await waits for an operation, and async with closes the client even if the model raises an error.

The program uses two known session IDs belonging to the tutorial user. It loads the first session explicitly before answering in the second. It does not search all users' messages to identify a shopper. The stored trace contains an application action and its tool result, not private model reasoning.

5. Store the first conversation and explicit preference

python conversation_memory.py seed

Expected: Stored: first conversation, explicit shopper preference, fictional product. The process exits and closes its database connection.

6. Answer a new question after restart

python conversation_memory.py resume

Expected milestones:

  1. Retrieved: 2 prior messages and 1 preferences.

  2. Verified: new response and product-lookup trace read back.

  3. A model answer to “What would you recommend for the trip I mentioned?”

Inspect the answer for the seeded wide-fit city-walking requirement, the USD 60 budget, and the fictional Trail Starter product priced at USD 45. The checks prove that the request contained retrieved context and that the resulting answer and tool record were stored, not that the wording is ideal; see Understanding the three memory types for that distinction.

A model failure completes the trace with success=False and raises the error. Fix provider access before retrying. The program does not label a failed request as a successful answer.

7. Verify the linked tool record

In the Aura console, open Query, select the lesson instance and database, and connect with the credentials exported in step 2. Run:

MATCH (trace:ReasoningTrace {session_id: 'docs-shopping-return'})
      -[:HAS_STEP]->(step:ReasoningStep)
      -[:USES_TOOL]->(call:ToolCall)
RETURN trace.success, step.action, call.tool_name

Expected: a successful trace, the action to read the tutorial product, and the lookup_product tool call. If you ran a failed attempt too, inspect each trace separately.

What we built

The second process read persisted context, included it in a real model request, and stored its response with a trace of the product lookup. Retrieval and authorization remain separate responsibilities: an application must authorize the session IDs and user identifier it passes to these APIs. See multi-tenancy before extending this single-user exercise.

Resume this lesson later

A new Python process keeps the same shell exports; a new terminal does not. If this lesson’s Aura instance still exists, resume it without creating another instance or asserting that its database is empty:

  1. Return to the existing folder and activate its environment:

    cd ~/agent-memory-tutorials
    source .venv/bin/activate
  2. Re-export the original instance’s NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD, and NEO4J_DATABASE from its saved credentials. Restore this page’s provider key/model/region settings too. Keep secrets out of the Python files.

  3. Check the same database:

    python wait_for_tutorial_neo4j.py
  4. Use the readback or inspection step described below. Do not repeat the seed/record/write command just to recover context. Preserve existing IDs and state until you know which operations completed.

If the instance was already destroyed, its stored results cannot be resumed; complete local cleanup and start a new empty lesson instance. A different lesson also starts with a fresh instance, while keeping the folder and environment.

Inspect the two session IDs in Aura Query and the trace check from this page first. resume makes another model request and writes a new turn/trace; run it only when you intend that additional operation. It is not a read-only reconnect command.

MATCH (conversation:Conversation)-[:HAS_MESSAGE]->(message:Message)
WHERE conversation.session_id IN ['docs-shopping-first', 'docs-shopping-return']
RETURN conversation.session_id, message.id, message.role, message.content
ORDER BY conversation.session_id, message.timestamp

Expected after the seed phase: the two original messages in docs-shopping-first. After a successful answer, docs-shopping-return also contains the new question and response. If it has only the question, inspect the failed trace before intentionally retrying resume.

Clean up

Cleanup ends this lesson and removes its stored results. To keep working with those results later, follow Resume this lesson later before cleanup.

In the Aura console, select only the agent-memory-tutorial instance created for this lesson. Use its trashcan action, enter its exact name and confirm Destroy.

Expected: the tutorial instance disappears from the instance list. This removes its data and snapshots, so verify the instance name before confirming. See Aura instance deletion for the console procedure.

Remove the downloaded credentials for that deleted instance and clear its connection variables:

unset NEO4J_URI NEO4J_USERNAME NEO4J_PASSWORD NEO4J_DATABASE

Keep the tutorial folder and virtual environment. Start each Aura tutorial with a new empty lesson instance and its new credentials; this also avoids carrying over vector indexes from a lesson using a different embedding dimension.

If a check fails

  • A connection or authentication failure: confirm the Aura instance is Running, check the exported connection settings, and rerun the readiness command from step 2.

  • An OpenAI access or quota error: correct the account/model configuration and rerun on a fresh tutorial database if the prior write stopped midway.

  • Missing readback data: verify that the write command completed against this Aura instance. The program stops on missing data rather than treating an empty result as success.