Use with LangChain

How to integrate neo4j-agent-memory with LangChain 1.x to build memory-enabled agents and chains backed by a persistent context graph.

Supported LangChain range: 1.x. The [langchain] extra installs langchain-core>=1.0,<2; the [langchain-agents] extra adds langchain>=1.0,<2 (which pulls LangGraph) for the create_agent middleware.

LangChain 1.0 retired the memory story this page used to teach. langchain_core.memory.BaseMemory no longer exists, and ConversationChain, ConversationBufferMemory, AgentExecutor and create_openai_functions_agent moved to the langchain-classic distribution. The supported shape is:

  • thread history — a BaseChatMessageHistory implementation (plus a LangGraph checkpointer when you use the graph runtime), and

  • context injection / persistencecreate_agent middleware hooks.

Both are what this library ships. See Legacy: pre-1.0 LangChain if you are maintaining a pre-1.0 application.

examples/langchain_agent.py is the runnable version of this page; examples/nams-langchain runs the same adapter against the hosted backend.

Overview

The library ships three LangChain adapters, each implementing a base class that exists in LangChain 1.x, plus one pass-through helper.

Adapter Implements Use it for

Neo4jAgentMemory

langchain_core.chat_history.BaseChatMessageHistory

Thread history for RunnableWithMessageHistory, and memory-context assembly for hand-rolled prompts.

Neo4jMemoryRetriever

langchain_core.retrievers.BaseRetriever

RAG over all three memory layers — messages, entities, preferences and traces.

Neo4jMemoryMiddleware

langchain.agents.middleware.AgentMiddleware

Give a create_agent agent memory: inject context before the model call, persist the turn after it.

llm_provider_from_langchain

Reuse your configured LangChain chat model as memory’s own LLM.

LangChain 1.x + Context Graph Architecture
┌──────────────────────────────────────────────────────────┐
│               create_agent(model, tools, …)              │
│                                                          │
│   abefore_model ──▶ awrap_model_call ──▶ aafter_model    │
│        │                   │                   │         │
│        │                   ▼                   │         │
│        │          inject memory block          │         │
│        │          into system message          │         │
│        ▼                                       ▼         │
│   persist user turn                    persist reply     │
│  ┌────────────────────────────────────────────────────┐  │
│  │              Neo4jMemoryMiddleware                 │  │
│  └───────────────────────┬────────────────────────────┘  │
└──────────────────────────┼───────────────────────────────┘
                           │
      Neo4jAgentMemory ────┤──── Neo4jMemoryRetriever
     (BaseChatMessage…)    │         (BaseRetriever)
                           ▼
┌──────────────────────────────────────────────────────────┐
│                   Neo4j Context Graph                    │
│   Messages ──── Entities ──── Preferences ──── Traces    │
└──────────────────────────────────────────────────────────┘

Prerequisites

  • Python 3.10+

  • A Neo4j database (or a hosted NAMS workspace — see Backends)

  • An API key for your model provider

# Chat history + retriever only
pip install "neo4j-agent-memory[langchain,openai]"

# Plus the create_agent middleware
pip install "neo4j-agent-memory[langchain-agents,openai]" langchain-openai

Pass-through your LangChain model

To avoid declaring the same model twice — once for the agent, once for memory’s entity extraction — convert your LangChain BaseChatModel into an LLMProvider:

import os

from langchain_openai import ChatOpenAI

from neo4j_agent_memory import MemoryClient, MemorySettings
from neo4j_agent_memory.integrations.langchain import (
    llm_provider_from_langchain,
)

# Configure your LangChain model however you normally would. Temperature is
# left at the provider default: the GPT-5 family rejects non-default values.
chat = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-5-mini"))

# Convert it to an LLMProvider for the memory client.
provider = llm_provider_from_langchain(chat)

settings = MemorySettings(
    neo4j={"password": os.environ["NEO4J_PASSWORD"]},
    llm=provider,
    embedding="openai/text-embedding-3-small",
)

The helper introspects the model’s class name and attributes (model_name, anthropic_api_key, openai_api_key, openai_api_base, …) and routes via from_provider. ChatOpenAI resolves to OpenAIProvider; ChatAnthropic to AnthropicProvider; everything else falls through to LiteLLM.

Note the spelling: model= is the current constructor argument. model_name= is a legacy alias.

For the full pattern across all integrations, see Migrate to Pluggable Providers — Pattern 5.

Give an agent memory with middleware

Neo4jMemoryMiddleware is the whole integration for an agent: nothing else to wire up, no prompt template to maintain.

import asyncio
import os

from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from pydantic import SecretStr

from neo4j_agent_memory import BoltSettings, MemoryClient, Neo4jConfig
from neo4j_agent_memory.integrations.langchain import (
    Neo4jMemoryMiddleware,
    llm_provider_from_langchain,
)


async def main() -> None:
    model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-5-mini"))

    settings = BoltSettings(
        neo4j=Neo4jConfig(
            uri=os.getenv("NEO4J_URI", "bolt://localhost:7687"),
            password=SecretStr(os.environ["NEO4J_PASSWORD"]),
        ),
        embedding="openai/text-embedding-3-small",
        llm=llm_provider_from_langchain(model),
    )

    async with MemoryClient(settings) as client:
        agent = create_agent(
            model,
            tools=[],
            system_prompt="You are a helpful assistant.",
            middleware=[
                Neo4jMemoryMiddleware(client, session_id="user-123"),
            ],
        )

        # Async invocation: the middleware implements the async hooks.
        result = await agent.ainvoke(
            {"messages": [("user", "Where should I eat tonight?")]}
        )
        print(result["messages"][-1].content)


asyncio.run(main())

What the middleware does on each turn:

abefore_model

Persists any user message the agent has not stored yet, so the turn survives a failed model call.

awrap_model_call

Calls client.get_context(query, session_id=…​) for the latest user message and appends the result to the request’s system message. Agent state is left untouched, so the memory block never accumulates in the thread’s message list.

aafter_model

Persists the assistant’s reply. Messages are de-duplicated by id, so a tool-calling loop that re-sends the same history does not write it repeatedly.

Options:

Neo4jMemoryMiddleware(
    client,
    session_id="user-123",
    include_short_term=True,   # conversation history in the injected block
    include_long_term=True,    # entities, facts and preferences
    include_reasoning=True,    # similar past traces
    max_items=10,              # per-layer cap
    store_messages=True,       # set False for a read-only agent
    extract_entities=True,     # entity extraction on persisted messages
    context_header="# Memory",
)

Only the async hooks are implemented, because neo4j-agent-memory is async all the way down. Invoke the agent with ainvoke or astream. The sync hooks raise NotImplementedError with that instruction rather than silently skipping memory.

Add tools that read the graph

create_agent takes plain @tool functions, and async tools are first class — no run_until_complete bridging:

import json

from langchain_core.tools import tool

from neo4j_agent_memory import MemoryClient


def build_memory_tools(client: MemoryClient) -> list:
    """Tools that query the context graph directly."""

    @tool
    async def search_entities(query: str, limit: int = 10) -> str:
        """Search the knowledge graph for people, places, products or organizations."""
        entities = await client.long_term.search_entities(query, limit=limit)
        return json.dumps(
            [
                {
                    "name": e.display_name,
                    "type": e.full_type,
                    "description": e.description,
                }
                for e in entities
            ]
        )

    @tool
    async def save_preference(category: str, preference: str) -> str:
        """Record a preference the user just expressed (category: food, brand, style, ...)."""
        await client.long_term.add_preference(category, preference)
        return f"Saved preference: {category} — {preference}"

    @tool
    async def search_history(query: str, session_id: str, limit: int = 5) -> str:
        """Search earlier messages in a conversation."""
        messages = await client.short_term.search_messages(
            query, session_id=session_id, limit=limit
        )
        return json.dumps(
            [{"role": m.role.value, "content": m.content[:200]} for m in messages]
        )

    return [search_entities, save_preference, search_history]


# agent = create_agent(model, tools=build_memory_tools(client), middleware=[...])

Thread history for a chain

Neo4jAgentMemory is a BaseChatMessageHistory, so it drops straight into RunnableWithMessageHistory for chains that are not agents:

from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory

from neo4j_agent_memory.integrations.langchain import Neo4jAgentMemory

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "You are a helpful assistant."),
        MessagesPlaceholder("history"),
        ("human", "{input}"),
    ]
)
chain = prompt | model | StrOutputParser()

chain_with_history = RunnableWithMessageHistory(
    chain,
    lambda session_id: Neo4jAgentMemory(memory_client=client, session_id=session_id),
    input_messages_key="input",
    history_messages_key="history",
)

answer = await chain_with_history.ainvoke(
    {"input": "I'm looking for running shoes"},
    config={"configurable": {"session_id": "user-123"}},
)

RunnableWithMessageHistory reads the history with aget_messages() and writes the turn back with aadd_messages() — both of which talk to Neo4j directly.

Use it directly

from langchain_core.messages import AIMessage, HumanMessage

history = Neo4jAgentMemory(memory_client=client, session_id="user-123")

await history.aadd_messages(
    [
        HumanMessage(content="I prefer spicy food"),
        AIMessage(content="Noted — I'll remember that."),
    ]
)

messages = await history.aget_messages()  # list[BaseMessage], oldest first
await history.aclear()                    # clears the session

Role mapping: HumanMessage"user", AIMessage"assistant", SystemMessage"system", and ChatMessage(role="tool")"tool". Messages with no text content (an AIMessage carrying only tool calls, say) are skipped — memory stores text.

The sync surface (history.messages, add_messages, clear) only works outside a running event loop; inside one it raises RuntimeError naming the coroutine to await. The async driver is bound to the loop that created it, so blocking on the caller’s own loop would deadlock. Prefer aget_messages / aadd_messages / aclear.

Assemble a memory context block

The same object can render a context block for a hand-rolled prompt:

memory = Neo4jAgentMemory(
    memory_client=client,
    session_id="user-123",
    include_short_term=True,
    include_long_term=True,
    include_reasoning=True,
    max_messages=10,
)

variables = await memory.aload_memory_variables({"input": "restaurant recommendation"})
# {"history": "...", "context": "...", "preferences": [...], "similar_tasks": "..."}

await memory.asave_context(
    {"input": "What's a good Thai restaurant?"},
    {"output": "Based on your preferences, I recommend Thai Kitchen!"},
)

memory.memory_variables lists the keys the current include_* flags produce.

aload_memory_variables / asave_context replaced the private _load_memory_variables_async / _save_context_async coroutines, which are kept as aliases for one release. Use the public names.

Retrieve from memory

Neo4jMemoryRetriever searches every memory layer and returns LangChain Document objects sorted by similarity:

from neo4j_agent_memory.integrations.langchain import Neo4jMemoryRetriever

retriever = Neo4jMemoryRetriever(
    memory_client=client,
    session_id="user-123",     # scopes message search
    search_short_term=True,    # messages
    search_long_term=True,     # entities + preferences
    search_reasoning=True,     # reasoning traces
    k=10,
    threshold=0.7,
)

docs = await retriever.ainvoke("spicy food preferences")
for doc in docs:
    print(doc.metadata["type"], "-", doc.page_content)

Each document’s metadata["type"] is one of message, entity, preference or trace, alongside id and similarity. Because it is a real BaseRetriever, it composes with anything that takes one — create_retrieval_chain, a retriever tool, LangSmith tracing — and ainvoke runs on the caller’s event loop.

Pass session_id whenever you can: it scopes message search, and the hosted NAMS backend requires it (its message search is conversation-scoped). Without it on NAMS, the message layer is skipped with a warning rather than failing the whole retrieval.

To let the agent decide when to search memory, hand the retriever to LangChain’s own tool wrapper instead of injecting context unconditionally:

from langchain_core.tools.retriever import create_retriever_tool

search_memory = create_retriever_tool(
    retriever,
    "search_memory",
    "Search the user's memory: past messages, known entities, saved preferences.",
)

# agent = create_agent(model, tools=[search_memory], middleware=[...])

Record reasoning traces

Write the agent’s run into reasoning memory with a second middleware, so you can later ask "what did the agent do, and which entities did it touch?":

from typing import Any

from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import AIMessage, HumanMessage

from neo4j_agent_memory import MemoryClient
from neo4j_agent_memory.schema.models import TraceOutcome


class ReasoningTraceMiddleware(AgentMiddleware):
    """Record each agent run as a reasoning trace."""

    def __init__(self, client: MemoryClient, session_id: str) -> None:
        super().__init__()
        self.client = client
        self.session_id = session_id
        self.tools = []

    async def abefore_agent(self, state: AgentState, runtime: Any) -> None:
        task = next(
            (m.text for m in reversed(state["messages"]) if isinstance(m, HumanMessage)),
            "",
        )
        trace = await self.client.reasoning.start_trace(self.session_id, task)
        self._trace_id = trace.id

    async def aafter_model(self, state: AgentState, runtime: Any) -> None:
        last = state["messages"][-1]
        if not isinstance(last, AIMessage):
            return
        step = await self.client.reasoning.add_step(
            self._trace_id,
            thought=last.text or "(tool call only)",
            action=", ".join(c["name"] for c in last.tool_calls) or "respond",
        )
        for call in last.tool_calls:
            await self.client.reasoning.record_tool_call(
                step.id,
                call["name"],
                call["args"],
            )

    async def aafter_agent(self, state: AgentState, runtime: Any) -> None:
        await self.client.reasoning.complete_trace(
            self._trace_id,
            outcome=TraceOutcome(
                success=True,
                summary=state["messages"][-1].text[:200],
                metrics={"model_calls": float(len(state["messages"]))},
            ),
        )

Add it alongside the memory middleware:

agent = create_agent(
    model,
    tools=tools,
    middleware=[
        Neo4jMemoryMiddleware(client, session_id=session_id),
        ReasoningTraceMiddleware(client, session_id=session_id),
    ],
)

Once traces exist, Neo4jMemoryMiddleware(include_reasoning=True) starts surfacing similar past runs in the injected block. Without them that layer is always empty, so leave include_reasoning=False until you record something.

See Audit Reasoning for the one-hop audit query over TOUCHED edges.

Running on the hosted backend

The adapters tolerate the hosted NAMS backend’s narrower surface instead of raising:

Call On NAMS Adapter behaviour

short_term.*

supported

full history and context

long_term.search_entities

supported

entity documents and context block

long_term.get_context

returns ""

falls back to entity search

long_term.search_preferences

NotSupportedError

preferences comes back []

reasoning.get_similar_traces

NotSupportedError

similar_tasks comes back ""

short_term.search_messages

needs a conversation id

pass session_id=, else the layer is skipped

Switching backends is a settings change; the adapter code is identical:

from neo4j_agent_memory import MemoryClient, MemorySettings

# Hosted: reads MEMORY_API_KEY (and optionally MEMORY_WORKSPACE_ID)
settings = MemorySettings(backend="nams")

async with MemoryClient(settings) as client:
    history = Neo4jAgentMemory(
        memory_client=client,
        session_id="user-123",
        include_reasoning=False,  # no trace search on NAMS
    )

Best practices

Use one session per conversation

from datetime import datetime, timezone

# Good: unique, attributable session
session_id = f"user-{user_id}-{datetime.now(timezone.utc):%Y%m%d%H%M%S}"

# Avoid: one session shared by every user
session_id = "global"

For a policy rather than an ad-hoc string, use MemoryIntegration with a SessionStrategy (PER_CONVERSATION, PER_DAY, PERSISTENT) — see Adapters reference.

Stay on the async path

Every adapter’s async surface talks to Neo4j directly; the sync surface exists only for genuinely synchronous callers and refuses to run inside an event loop. Use ainvoke / astream on agents and chains, and the a* methods on the adapters.

Cap what you inject

Neo4jMemoryMiddleware(client, session_id=session_id, max_items=5)
Neo4jAgentMemory(memory_client=client, session_id=session_id, max_messages=10)
Neo4jMemoryRetriever(memory_client=client, session_id=session_id, k=5)

Memory competes with the rest of the prompt for context; fewer, better items usually beat more.

Do not let memory break the agent

The middleware already degrades on unsupported layers, but storage errors propagate. Wrap the run if a memory outage must not take the agent down:

import logging

logger = logging.getLogger(__name__)

try:
    result = await agent.ainvoke({"messages": [("user", question)]})
except Exception:
    logger.warning("memory-backed agent run failed; retrying without memory")
    plain = create_agent(model, tools=tools, system_prompt=system_prompt)
    result = await plain.ainvoke({"messages": [("user", question)]})

Legacy: pre-1.0 LangChain

If you are maintaining an application built on LangChain 0.x, the classes the earlier version of this guide used now live in the langchain-classic distribution:

Pre-1.0 import 1.x location

langchain_core.memory.BaseMemory

removed

langchain.memory.ConversationBufferMemory

langchain_classic.memory.ConversationBufferMemory

langchain.memory.ConversationBufferWindowMemory

langchain_classic.memory.ConversationBufferWindowMemory

langchain.chains.ConversationChain

langchain_classic.chains.ConversationChain

langchain.agents.AgentExecutor

langchain_classic.agents.AgentExecutor

langchain.agents.create_openai_functions_agent

langchain_classic.agents.create_openai_functions_agent

Neo4jAgentMemory can still back a classic ConversationBufferMemory through its chat_memory= argument, because that argument takes a BaseChatMessageHistory — which is exactly what Neo4jAgentMemory now is. New code should use middleware or RunnableWithMessageHistory instead.