AWS Strands Agents Integration
|
This is the Python guide, which covers both backends, the hosted NAMS and
the self-hosted Neo4j or Aura over Bolt. If you are using TypeScript, see
Strands Agents (TypeScript). That SDK
wires up different Strands surfaces ( |
This guide shows how to add Neo4j Context Graph memory to AWS Strands agents. In Strands' own vocabulary: the session manager restores sessions, the memory store feeds the agent loop. Three constructs, usable independently or together on the same agent:
| Construct | What it does | Reach for it when |
|---|---|---|
Memory store ( |
|
The preferred way to give an agent memory. Needs |
Session manager (push-based) |
|
You want zero-effort transcript capture and continuity — nothing depends on the model remembering to call a tool. |
Memory tools (pull-based) |
|
Deep graph queries the store doesn’t cover — or a |
Overview
All three constructs write into the same knowledge graph, which is what enables the multi-agent "shared brain" shown above: what one agent learns, every other agent can retrieve (see The Shared-Brain Pattern).
Installation
pip install neo4j-agent-memory[aws,strands]
The extra resolves strands-agents>=1.52,<2. Both adapters are tested against
1.52 and 1.55.1.
Bedrock model ids
Current-generation Claude models on Bedrock are invoked through a cross-region
inference profile — a <prefix>.anthropic.… id such as
us.anthropic.claude-sonnet-4-6, not the bare foundation-model id. Which
prefixes (us, eu, apac, global) and which models your account can
invoke is region- and account-specific, so the snippets below resolve the id
rather than hard-coding one:
from neo4j_agent_memory.integrations.strands import (
bedrock_embedding_model,
bedrock_llm_model,
)
bedrock_llm_model() # us.anthropic.claude-sonnet-4-6
bedrock_llm_model("claude-opus") # us.anthropic.claude-opus-5
bedrock_embedding_model() # amazon.titan-embed-text-v2:0
BEDROCK_MODEL_ID overrides the resolved LLM id verbatim,
BEDROCK_INFERENCE_PROFILE_PREFIX swaps just the prefix, and
BEDROCK_EMBEDDING_MODEL_ID overrides the embedding id. Confirm an id with
aws bedrock list-inference-profiles before deploying.
Quick Start
Neo4jMemoryStore is the preferred way to give a Strands agent memory —
long-term recall injected straight into the agent loop:
from strands import Agent
from strands.memory import ExtractionConfig, InvocationTrigger, MemoryManager
from neo4j_agent_memory import MemorySettings
from neo4j_agent_memory.integrations.strands import (
Neo4jMemoryStore,
Neo4jMemoryStoreConfig,
bedrock_llm_model,
)
settings = MemorySettings(
neo4j={"uri": "neo4j+s://xxx.databases.neo4j.io", "password": "your-password"},
)
store = Neo4jMemoryStore(
Neo4jMemoryStoreConfig(
name="graph",
settings=settings,
user_id="user-123",
extraction=ExtractionConfig(trigger=InvocationTrigger()),
)
)
agent = Agent(
model=bedrock_llm_model(), # cross-region inference-profile id
memory_manager=MemoryManager(stores=[store]),
)
agent("Remember that I prefer Python over JavaScript")
extraction is off by default and MemoryManager disables its add_memory
tool by default, so a store left at both defaults recalls but never writes.
InvocationTrigger() extracts on every turn; bare extraction=True means
every fifth. Writes go through add_messages, which the backend extracts
server-side — no extra model call.
Add Neo4jSessionManager for transcript persistence/restore (see
Pairing with the session manager) and context_graph_tools for deep
graph queries the store doesn’t cover — all three are independent and
combine on one agent:
from neo4j_agent_memory.integrations.strands import (
Neo4jSessionManager,
bedrock_llm_model,
context_graph_tools,
)
tools = context_graph_tools(
neo4j_uri="neo4j+s://xxx.databases.neo4j.io",
neo4j_password="your-password",
embedding_provider="bedrock",
)
# The session manager owns extraction here, so this store is recall-only:
# a second config with extraction left at its default — see the pairing rule.
store = Neo4jMemoryStore(
Neo4jMemoryStoreConfig(name="graph", settings=settings, user_id="user-123")
)
manager = Neo4jSessionManager("support-42", settings=settings, extract_entities=True)
agent = Agent(
model=bedrock_llm_model(), # cross-region inference-profile id
tools=tools,
session_manager=manager,
memory_manager=MemoryManager(stores=[store]),
)
Using the hosted NAMS service instead of self-hosted Neo4j? for_nams() on
the store and the session manager, and nams_context_graph_tools(), all
read MEMORY_API_KEY (and optionally MEMORY_ENDPOINT) from the
environment:
from neo4j_agent_memory.integrations.strands import (
Neo4jMemoryStore,
Neo4jMemoryStoreConfig,
Neo4jSessionManager,
nams_context_graph_tools,
)
store = Neo4jMemoryStore.for_nams(Neo4jMemoryStoreConfig(name="graph"))
manager = Neo4jSessionManager.for_nams("support-42")
tools = nams_context_graph_tools()
|
Do not share a |
Each construct also works on its own — the following sections cover the store, the tools, and the session manager independently.
Memory Store
Neo4jMemoryStore implements Strands' MemoryStore protocol. Hand it to
MemoryManager(stores=[…]) on Agent(memory_manager=…) and the manager
searches it for context and routes add_memory/programmatic add() calls
to it.
Configuration
Neo4jMemoryStoreConfig is a dataclass — every field is a checked
attribute, not a dict key. Pass exactly one of client (a pre-connected
MemoryClient, left open) or settings (the store builds and owns one).
| Field | Default | Purpose |
|---|---|---|
|
(required) |
Store name; also names the deterministic sink conversation. |
|
— |
Exactly one of a pre-connected |
|
auto |
Shown to the model as the store’s purpose. |
|
|
Cap on the total rows one |
|
|
|
|
|
Truthy enables store-side extraction. Leave |
|
minted |
Explicit write-sink override; otherwise a deterministic name is minted in
|
|
|
Scopes writes to one tenant — on |
|
|
Search fan-out. Preferences and facts are auto-gated off on NAMS ( |
|
|
Similarity floor. bolt only — NAMS ignores it. |
|
|
Whether |
from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig
store = Neo4jMemoryStore(
Neo4jMemoryStoreConfig(name="graph", client=client, user_id="user-123")
)
search(), add(), add_messages()
search(query) fans out over entities, preferences, and facts and returns
MemoryEntry objects: content is a formatted line, metadata carries
kind (entity/preference/fact), id, type, and — bolt only —
score. Entities only on NAMS: search_preferences/search_facts
raise NotSupportedError there, so those two kinds are dropped from the
fan-out automatically.
add(content, metadata=None) writes a message into the store’s sink
conversation with extraction on by default — the one write path every
backend supports. Set metadata["kind"] to "preference", "fact", or
"entity" to route to a typed write (add_preference/add_fact/add_entity)
instead; a kind unsupported on the current backend (e.g. "fact" on NAMS)
falls back to the default sink and logs a warning once per store.
add_messages(messages, context) bulk-ingests a batch of conversation turns
into the sink with extraction on — server-side on NAMS, inline on bolt —
so no extra model call happens. This is what a MemoryManager with
extraction truthy calls automatically.
Graph tools
get_tools() returns graph-native tools the manager itself can’t provide,
bound to the store’s own client. Names are prefixed with the store’s name
so they coexist with the tools factory’s (see Limitations) —
a store named graph yields graph_get_entity_graph:
| Tool | bolt | NAMS | Notes |
|---|---|---|---|
|
yes |
yes |
Configurable |
|
yes, if |
omitted |
Bolt-only, and only shipped when the store has a configured |
Pairing with the session manager
The session manager restores sessions; the memory store feeds the agent loop — different jobs, and pairing both on one agent is supported and recommended:
| Shape | Behaviour |
|---|---|
Store alone |
Store owns extraction ( |
Store + session manager (recommended) |
Store stays recall-only ( |
Store + session manager, with the store also extracting |
Raises at construction — both would write and extract the same turns twice. Exception: bolt with the session manager’s own |
Recommended configuration:
Neo4jSessionManager(..., extract_entities=True) # transcript + extraction
Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph")) # recall only (extraction=False default)
If both extract, Neo4jSessionManager raises at agent construction:
Neo4jSessionManager and Neo4jMemoryStore 'graph' would write and extract the same turns twice. Set extraction=False on Neo4jMemoryStore (recall only, recommended), or extract_entities=False on Neo4jSessionManager (let the store own extraction).
Separately, Neo4jSessionManager’s own `Neo4jRetrievalConfig and the
manager’s default injection both fold context into the last user message —
pairing them injects memory twice. The session manager logs a warning
(once) naming the fix; see Retrieval Injection.
Limitations
-
The sink conversation is separate from the readable chat history. Paired with
Neo4jSessionManager, a turn the store ingests exists twice in the graph: once in the restorable transcript, once in the store’s sink. The duplication is inMessagenodes, not knowledge — entities converge through resolution and dedupe. -
Retry dedupe is in-process only.
AddMessagesContext.sequence_numbersreset every run, so the(run_id, sequence_number)set that skips already-written turns cannot outlive the process. A restart in the middle of a retried extraction batch can duplicate messages in the sink. -
Pointing
conversation_idat the chat conversation is unsupported. It duplicatesMessagenodes inside the readable history, so restored transcripts gain phantom turns. Not guarded — leaveconversation_idunset and let the store mint its own sink. -
Owned clients rebind across event loops; borrowed ones do not. Strands' synchronous
Agent(…)runs each call on a fresh event loop, and both Neo4j transports bind to the loop that opened them. A store built fromsettings=owns its client and reconnects it when the loop changes (one reconnect per invocation). A client passed asclient=is never closed or reconnected on your behalf: a loop change raises, naming both remedies. Drive the agent from your own loop, or hand the storesettings=. -
Tool names are namespaced.
get_tools()prefixes each tool with the store’sname(graph_get_entity_graph), so the store’s tools coexist withcontext_graph_tools’ identically-purposed `get_entity_graph/get_user_preferences— which take different arguments — instead of silently replacing them in the agent’s tool registry.
See examples/strands-memory-store/ for a runnable demo (search(),
add(), get_tools()) that requires no LLM or API key.
Memory Tools (Pull-Based)
The integration provides four memory tools that agents can use:
| Tool | Purpose |
|---|---|
|
Semantic search across memories and entities |
|
Explore relationships around an entity |
|
Store information with entity extraction |
|
Retrieve user preferences by category |
|
|
|
When a session manager is attached (see Session Manager (Push-Based)),
conversation capture is automatic — |
Configuration
Basic Configuration
from neo4j_agent_memory.integrations.strands import context_graph_tools
tools = context_graph_tools(
# Neo4j connection
neo4j_uri="neo4j+s://xxx.databases.neo4j.io",
neo4j_user="neo4j",
neo4j_password="your-password",
neo4j_database="neo4j",
# Embedding configuration
embedding_provider="bedrock", # or "openai", "vertex_ai"
embedding_model="amazon.titan-embed-text-v2:0",
# AWS configuration (for Bedrock)
aws_region="us-east-1",
)
Using StrandsConfig
from neo4j_agent_memory.integrations.strands import StrandsConfig, context_graph_tools
config = StrandsConfig(
neo4j_uri="neo4j+s://xxx.databases.neo4j.io",
neo4j_password="your-password",
embedding_provider="bedrock",
embedding_model="amazon.titan-embed-text-v2:0",
aws_region="us-east-1",
)
tools = context_graph_tools(**config.to_dict())
From Environment Variables
from neo4j_agent_memory.integrations.strands import StrandsConfig, context_graph_tools
# Reads from environment variables
config = StrandsConfig.from_env()
tools = context_graph_tools(**config.to_dict())
Required environment variables:
export NEO4J_URI=neo4j+s://xxx.databases.neo4j.io
export NEO4J_PASSWORD=your-password
export AWS_REGION=us-east-1
Pass-Through Your Strands Model
Strands agents typically use Bedrock model strings (e.g.
"us.anthropic.claude-sonnet-4-6"). llm_provider_from_strands accepts either
a bare string or a model object and prepends bedrock/ when no provider prefix
is present:
from neo4j_agent_memory import MemoryClient, MemorySettings
from neo4j_agent_memory.integrations.strands import (
llm_provider_from_strands,
)
provider = llm_provider_from_strands(
"us.anthropic.claude-sonnet-4-6",
)
settings = MemorySettings(
neo4j={"password": "p"},
llm=provider,
embedding="bedrock/amazon.titan-embed-text-v2:0",
)
The context_graph_tools(…) helper already resolves the embedder via from_provider internally — the pass-through is only needed when you want the same model used by both the agent and memory extraction.
Tool Details
search_context
Search for relevant memories and entities:
# Agent calls this tool automatically
result = search_context(
query="What do I know about the Acme project?",
user_id="user-123",
top_k=10, # Max results
min_score=0.5, # Similarity threshold
include_relationships=True,
)
Returns:
-
Matching messages with similarity scores
-
Related entities
-
Entity relationships (if enabled)
get_entity_graph
Explore relationships around an entity:
result = get_entity_graph(
entity_name="Acme Corp",
user_id="user-123",
depth=2, # Relationship depth
relationship_types=None, # All types, or filter
)
Returns:
-
Entity details
-
Connected entities
-
Relationship types and properties
add_memory
Store information with automatic entity extraction:
result = add_memory(
content="John Smith from Acme Corp called about the Q4 project",
user_id="user-123",
session_id="session-456",
extract_entities=True, # Auto-extract entities
)
Returns:
-
Memory ID
-
Extracted entities (if enabled)
-
Entity relationships created
Agent System Prompts
Guide your agent to use memory effectively:
agent = Agent(
model=bedrock_llm_model(), # cross-region inference-profile id
tools=tools,
system_prompt="""You are an assistant with persistent memory.
MEMORY USAGE:
1. At the start of a conversation, use search_context to find relevant history
2. When the user mentions entities (people, companies), use get_entity_graph
3. Store important facts with add_memory
4. Check get_user_preferences for personalization
Always reference your memory when it's relevant to the conversation.""",
)
Error Handling
Tools return error information in results:
# If Neo4j is unavailable
{
"error": "ConnectionError",
"message": "Failed to connect to Neo4j",
"memories": [],
"entities": []
}
Configure the agent to handle errors gracefully:
agent = Agent(
tools=tools,
system_prompt="""...
If a memory tool returns an error, acknowledge it and continue
without that context. Don't expose internal errors to users.""",
)
Session Manager (Push-Based)
Neo4jSessionManager plugs into the Strands session lifecycle: pass it to
Agent(session_manager=…) and every turn is persisted automatically,
conversation history is restored on agent restart, and (opt-in) relevant
long-term memories are injected into each user message before the model sees
it. Nothing depends on the model deciding to call a tool.
Construction
For self-hosted Neo4j, pass settings= and the manager owns the client
lifecycle:
from neo4j_agent_memory import MemorySettings
from neo4j_agent_memory.integrations.strands import Neo4jSessionManager
settings = MemorySettings(
neo4j={"uri": "bolt://localhost:7687", "password": "password"},
)
manager = Neo4jSessionManager("support-42", settings=settings)
For the hosted NAMS service, for_nams reads MEMORY_API_KEY (and
optionally MEMORY_ENDPOINT) from the environment — the same conventions as
nams_context_graph_tools():
from neo4j_agent_memory.integrations.strands import Neo4jSessionManager
manager = Neo4jSessionManager.for_nams("support-42")
Alternatively, pass an already-constructed (but not yet connected)
MemoryClient via memory_client=; see the loop-binding note under
Limitations.
Retrieval Injection
|
For new code, |
When a Neo4jRetrievalConfig is supplied, each user message triggers
concurrent long-term searches (entities, preferences, and optionally facts).
Matching results are prepended inside a <user_context> block:
<user_context>
Relevant memory:
- [entity] Acme Corp (ORGANIZATION) — customer since 2024, owner: Jane Doe
- [preference] communication: prefers concise answers
</user_context>
{original user text}
The stored message is always the user’s original — injection is in-memory
only and idempotent per message. If nothing clears the min_score floor,
nothing is injected (no empty tags). The cost is 1–3 backend round-trips per
user turn (why it is opt-in).
|
On NAMS, only entity search is available — NAMS does not yet expose
preference or fact search endpoints, so |
from neo4j_agent_memory.integrations.strands import Neo4jRetrievalConfig
retrieval_config = Neo4jRetrievalConfig(
top_k=10, # max results per memory kind
min_score=0.2, # similarity floor
include_entities=True,
include_preferences=True,
include_facts=False, # set True to also search stored facts
context_tag="user_context", # XML wrapper tag (AgentCore-compatible default)
)
Constructor Reference
| Parameter | Default | Description |
|---|---|---|
|
(required) |
Strands session identifier (maps to one |
|
|
Pre-constructed |
|
|
|
|
|
Scopes writes to a specific user/tenant (multi-tenant deployments). |
|
|
Enable long-term memory injection. |
|
|
Run entity extraction on stored messages (bolt only; NAMS extracts server-side regardless). |
|
|
Mirror tool-use and tool-result blocks into reasoning memory for audit. |
|
|
Seconds to wait for each sync→async backend call before raising. |
|
|
Max messages loaded into the agent on restore; |
Limitations
-
Memory-grade persistence. Text turns are stored and restored; tool-use blocks are not replayed on restart (set
record_tool_calls=Trueto mirror them into reasoning memory for audit instead). -
agent.stateand conversation-manager window state do not survive restarts. No Strands-specific node types are written to the graph;sync_agentis a no-op — the message buffer is flushed by theAfterInvocationEventhook. -
Redaction. Fully supported within an invocation — the write-behind buffer lets guardrails rewrite the latest message before it ever reaches the backend. After the buffer has been flushed, falls back to delete-and-re-add on bolt (the redacted message moves to the end of restored history) and logs a warning on NAMS (no message-update endpoint on NAMS, so server-side redaction is not possible).
-
External
AfterInvocationEventhooks registered after the session manager run before the final turn is flushed (Strands dispatches that event in reverse registration order) — read persisted state on the next turn instead. -
Search scope. Long-term searches are workspace/database-scoped.
user_idscopes writes (multi-tenant), not searches — the search APIs take no user filter. -
One
Agentper session manager instance; no Graph/Swarm or bidirectional persistence. Strands'SessionManagerbase class registers multi-agent andBidiAgenthooks unconditionally, and its implementations of them raiseNotImplementedError— so attachingNeo4jSessionManagerto a Graph, a Swarm, or aBidiAgentfails at the first dispatch, naming the class in the error. Those topologies want one of Strands' own repository-backed managers (FileSessionManager,S3SessionManager,RepositorySessionManager). The shared-brain pattern below is N independent agents, each with its own manager, over one graph — not a Strands multi-agent orchestrator. -
Loop-bound transports. Pass
settings=(or an unconnectedmemory_client=). A client already connected on another event loop cannot be driven from the manager’s background loop — the transport is bound to the loop that performed the firstconnect(). -
Multiple conversations with the same session ID (NAMS). If external writers create more than one NAMS conversation with the same
strands_session_idmetadata, the manager resolves to the first one listed. Avoid duplicate session IDs in shared workspaces; resolution passesuser_idand an explicit page limit to narrow the scan.
Multi-User Support
Handle multiple users in the same application. The tools take a user_id
argument on each call; the session manager takes user_id at construction
(one manager per user session):
# Tools are configured per-invocation
# The agent passes user_id with each tool call
agent = Agent(
model=bedrock_llm_model(), # cross-region inference-profile id
tools=tools,
session_manager=Neo4jSessionManager(
f"support-{user_id}",
settings=settings,
user_id=user_id, # scopes writes in multi-tenant deployments
),
system_prompt="""The current user is {user_id}.
Always include user_id="{user_id}" in your memory tool calls.""",
)
# Inject user context
response = agent(
f"[User: user-123] What do you remember about me?",
)
The Shared-Brain Pattern
Each agent gets its own Neo4jSessionManager (its own session_id), all
pointing at the same graph. Conversations flow into one knowledge graph;
entities extracted from one session are searchable by all others — this is
the multi-agent architecture from the Overview diagrams.
from neo4j_agent_memory import MemorySettings
from neo4j_agent_memory.integrations.strands import (
Neo4jRetrievalConfig,
Neo4jSessionManager,
)
settings = MemorySettings(
neo4j={"uri": "bolt://localhost:7687", "password": "password"},
)
# Agent A — ingests from KYC conversations
manager_a = Neo4jSessionManager(
"kyc-session",
settings=settings,
extract_entities=True,
)
# Agent B — queries the shared graph with context injection
manager_b = Neo4jSessionManager(
"credit-session",
settings=settings,
retrieval_config=Neo4jRetrievalConfig(),
)
See examples/strands-session-manager/ for a runnable four-phase demo (persist, inject, restore, reasoning/tool-call mirror) that
requires no LLM or API key.