Integrate with AWS Strands Agents
|
This is the TypeScript guide, and it is NAMS-only. The TypeScript client has no Bolt transport. If you are using Python, see AWS Strands Agents (Python). That SDK covers both backends and exposes a different surface set (session manager with opt-in retrieval injection, plus agent-callable memory tools). |
The @neo4j-labs/agent-memory/integrations/strands subpath plugs into
all four of Strands' orthogonal extension surfaces:
-
MemoryStore— long-term recall injected into the agent loop.MemoryManagersearches the graph before each model call and folds the hits into the turn. The preferred way to give a Strands agent memory — see Quick Start below. -
SnapshotStorage— session state persists to a NAMS conversation automatically. Real conversation messages land asMessagegraph nodes; non-message snapshot state (Strands'dataminus messages, plusappData, plus manifests) rides on syntheticrole: "user"marker messages whose content is__strands_state__:{base64-JSON}. -
ConversationManager— three-tier context (reflections + observations fromgetContext()) is prepended to every model invocation, on top of a configurable inner manager (defaults toSlidingWindowConversationManager). -
HookRegistryevents —BeforeInvocation,AfterInvocation,BeforeToolCall, andAfterToolCallare wired to the reasoning subclient so every agent turn becomes a queryableReasoningStepwith tool calls attached.
In Strands' own vocabulary: the session storage restores sessions, the memory store feeds the agent loop.
A runnable example lives at
typescript/examples/strands.
Prerequisites: a Strands JS agent, Node.js 22+ and NAMS test-workspace credentials.
Procedure
-
Build the shared SDK before installing the Strands example; its local dependency is copied by npm.
-
Use
connectMemoryToAgentfor session/context/reasoning wiring, as shown below. -
Add the memory-store surface only when your application needs explicit entity recall, then inspect stored messages using the synthetic-message filter.
Quick start
import { Agent, MemoryManager } from "@strands-agents/sdk";
import { MemoryClient } from "@neo4j-labs/agent-memory";
import { Neo4jMemoryStore } from "@neo4j-labs/agent-memory/integrations/strands";
const memory = new MemoryClient(); // reads MEMORY_API_KEY
const store = new Neo4jMemoryStore({ name: "graph", client: memory, userId: "user-123" });
const agent = new Agent({
model,
memoryManager: new MemoryManager({ stores: [store] }),
});
extraction is off by default and MemoryManager's add_memory tool is
opt-in, so a store left at both defaults recalls but never writes. Three ways
to get memories in:
-
set
extractionon the store, -
pair it with
Neo4jSessionStorage/connectMemoryToAgent(the recommended shape, below), or -
set
addToolConfig: trueon the manager
Neo4jMemoryStore.forNams({ name: "graph" }) is the form that builds and owns
its own client from the environment.
When to use this
You want any of:
-
Long-term entity recall injected into every model call, with no manual retrieval step
-
Agent state that survives process restarts AND is queryable as a graph
-
Reflections/observations injected into the model context automatically
-
A full reasoning trace of every agent turn, browseable later via
client.reasoning.getTraceByConversation(id) -
Cross-agent memory sharing through the same conversation
Memory store
Neo4jMemoryStore implements Strands' MemoryStore over a NAMS context
graph.
name-
Required. Identifies the store to
MemoryManager(search/add tool targeting). client/clientOptions-
Exactly one.
clientborrows a liveMemoryClient(left open onclose());clientOptionsbuilds and owns one. description-
Shown in tool descriptions. Defaults to a generic "entities extracted from conversations" line.
maxSearchResults-
Default result cap when a caller doesn’t pass a per-call limit.
writable-
Defaults to
true. extraction-
boolean | ExtractionConfig. Defaults tofalse(recall-only). conversationId-
Explicit write sink. Omit to use a deterministic, name-derived sink conversation.
userId-
Scopes the sink conversation: it names the sink, is recorded on it, and filters the scan that finds it. Nothing else is narrowed —
searchEntitiestakes no user filter, andadd({ kind: "entity" })reaches the workspace-wideaddEntity. Not tenant isolation. includeEntities-
Defaults to
true.falseturns off automatic recall only; setgraphTools: falseas well to stop the model reading the graph through the tool. graphTools-
Defaults to
true. Exposes the store’sget_entity_graphtool fromgetTools().
search(query, options) — entities only. Returns each hit as a
MemoryEntry with no score (NAMS returns no similarity for entity search).
add(content, metadata) — metadata.kind routes the write:
-
"entity"→longTerm.addEntity -
"preference"→longTerm.addPreference— no REST route on hosted NAMS, so this always falls back to the sink -
"fact"→longTerm.addFact(requiressubject/predicate/objectin metadata) — likewise unsupported on hosted NAMS, so it always falls back -
anything else, or a kind the backend doesn’t support → falls back to a message in the sink conversation, which NAMS extracts server-side. A memory is never silently dropped.
addMessages(messages, context) — the extraction write path. Chunks
into batches of 100 (the bulkAddMessages limit) and dedupes retried batches
using the manager’s per-message sequence numbers, so a retried save doesn’t
double-write. The dedupe is in-process: the bookkeeping does not survive a
restart, so a process that dies mid-retry can leave duplicate messages in the
sink conversation.
getTools() — one tool, <store-name>_get_entity_graph, when
graphTools is true. Its neighbourhood is capped at 50 nodes and 50 edges,
with edges filtered to the nodes that survived the cap; a trimmed result carries
truncated: true.
Sink discovery, and where it gives out
Writes go to one sink conversation per userId/name pair, found by a
metadata tag so restarts reuse it rather than accumulating orphans. Two limits
come from the hosted API rather than from this integration:
-
The scan reads the newest 1000 conversations.
listConversationspages through the service’s 200-per-page cursor up to that limit, so auserIdwith more conversations than that can push its own sink out of view, and the store creates a second one. Pass an explicitconversationIdto pin the sink if you expect that volume. -
Scan-then-create is not atomic. There is no create-if-absent endpoint, so two processes starting simultaneously against the same
userId/namecan each create a sink. Within one process concurrent writes are safe — resolution is memoised on the first call. Ingestion split across two sinks still converges in the graph, since entity resolution runs on the extracted entities, not on the conversation.
Recall is entities-only: the hosted service exposes search_entities
but no search_preferences or search_facts route, so includePreferences
and includeFacts are absent from the options type — a compile error if you
reach for them, not a silently ignored flag. get_entity_graph traverses
one hop via longTerm.expandGraph, not a configurable depth, for the same
reason: no multi-hop traversal endpoint.
Pairing with session persistence
The recommended shape: Neo4jSessionStorage (or Neo4jConversationManager)
persists and restores the transcript, and the Neo4jMemoryStore recalls —
with its own extraction left off. Both read and write through the same
MemoryClient.
Neo4jConversationManager.initAgent throws if it’s paired with a
Neo4jMemoryStore that has extraction enabled: the hosted service already
extracts every message write server-side, so a store that also extracts
would ingest the same turns twice. Set extraction: false on the store (the
recommended fix — the session integration keeps persisting the transcript),
or drop the Neo4j session integration and let the store own ingestion. There
is no equivalent guard when Neo4jSessionStorage is used on its own,
without Neo4jConversationManager — storage never receives an agent, so
there is nowhere to attach one.
Unlike the Python guide, there is no double-injection warning here.
Neo4jConversationManager injects reflections and observations from
getContext(); MemoryManager injects entity recall from the store.
Different tiers, no duplicated query, so running both together is supported
and additive rather than a misconfiguration. If you want only one block in
the prompt, turn the other off: injection: false on MemoryManager, or
includeReflections/includeObservations: false on
Neo4jConversationManager.
One-line wiring with connectMemoryToAgent
import { Agent } from "@strands-agents/sdk";
import { OpenAIModel } from "@strands-agents/sdk/models/openai";
import { MemoryClient } from "@neo4j-labs/agent-memory";
import { connectMemoryToAgent } from "@neo4j-labs/agent-memory/integrations/strands";
const memory = new MemoryClient();
// Resume rather than restart: both session storage and context injection are
// conversation-scoped, so recall across runs needs the same conversation id.
const userId = "alice";
const previous = (await memory.shortTerm.listConversations({ userId, limit: 1 }))[0];
const conv = previous ?? (await memory.shortTerm.createConversation({ userId }));
const agent = new Agent({
...await connectMemoryToAgent(memory, { conversationId: conv.id }),
model: new OpenAIModel({ modelId: process.env.OPENAI_MODEL ?? "gpt-5-mini" }),
tools: [/* ... */],
});
await agent.invoke("Tell me about graph databases.");
The factory returns { sessionManager, conversationManager } — spread it
into new Agent({…}). Reasoning hooks attach automatically when the
conversation manager’s initAgent runs.
|
The spread needs one resolved copy of |
Individual pieces (advanced)
For finer control:
import {
SessionManager,
SlidingWindowConversationManager,
type ConversationManager,
} from "@strands-agents/sdk";
import {
Neo4jSessionStorage,
Neo4jConversationManager,
registerReasoningHooks,
} from "@neo4j-labs/agent-memory/integrations/strands";
const sessionManager = new SessionManager({
sessionId: conv.id,
storage: { snapshot: new Neo4jSessionStorage(memory) },
});
const conversationManager = new Neo4jConversationManager(memory, {
conversationId: conv.id,
inner: new SlidingWindowConversationManager(), // or any custom CM
includeReflections: true,
includeObservations: true,
});
// Neo4jConversationManager matches ConversationManager's public surface but
// does not extend Strands' abstract class, so strict TypeScript needs a cast.
const agent = new Agent({
sessionManager,
conversationManager: conversationManager as unknown as ConversationManager,
model,
tools,
});
// If you skipped connectMemoryToAgent, attach reasoning hooks explicitly:
await registerReasoningHooks(memory, agent, { conversationId: conv.id });
What gets stored where
| Strands construct | Where it lands in NAMS |
|---|---|
Conversation messages |
|
Agent state ( |
synthetic |
Application state ( |
inside the same synthetic message’s content blob |
Tool calls |
|
Per-turn reasoning |
|
Manifests (snapshot metadata) |
synthetic |
NAMS exposes no endpoint to update a Conversation’s metadata after
creation, and we observed that per-message metadata doesn’t reliably
round-trip via GET /conversations/{id}/messages either. So the
integration stuffs the serialized blob directly into the message’s
content field, base64-encoded after a recognizable prefix. Each
saveSnapshot writes one synthetic message; listSnapshotIds walks
the message list, filters by the __strands_state__: content prefix,
and dedupes by snapshotId (last-write-wins for the isLatest flag).
Synthetic markers are filtered out before the Snapshot is handed back
to Strands on loadSnapshot, so the agent never sees them in its
prompt. Consumers walking the raw message list themselves (chat UIs,
Cypher queries, custom analytics) should filter on the prefixes too —
the integration exports isSyntheticStrandsMessage and
SYNTHETIC_MESSAGE_PREFIXES for convenience:
import { isSyntheticStrandsMessage } from "@neo4j-labs/agent-memory/integrations/strands";
const conv = await memory.shortTerm.getConversation(conversationId);
const realMessages = conv.messages.filter((m) => !isSyntheticStrandsMessage(m));
A UI rendering only the agent.invoke stream does not encounter stored
snapshot messages. A consumer fetching the raw conversation must apply this
filter before presenting messages to a user.
Behaviour details
ConversationManager layering
Neo4jConversationManager does NOT replace your inner manager’s trimming
or summarization logic — it layers context injection on top of it. So:
-
Sliding-window trimming still happens (if you use
SlidingWindowConversationManager) -
Summarization still happens (if you use
SummarizingConversationManager) -
If
getContext()fails (cold conversation, transient error), the inner manager’s output is used unchanged
Reasoning capture is best-effort
Every reasoning write (recordStep, recordToolCall) is wrapped in
try/catch. A failed reasoning write logs through the constructor logger
but never breaks the agent run. If you depend on the trace being complete,
check getTraceByConversation(id) after the run and replay missing steps
yourself.
Version compatibility
The source declares @strands-agents/sdk >=1.16 <2 and Node.js 22+.
The tests check installed typings; this describes source compatibility, not
a verified npm artifact. Strands' v1 API
is stable under semver, and typescript/test/unit/strands/type-compatibility.test.ts
asserts each of our classes still matches the Strands interface it implements
(for Neo4jConversationManager, the name, reduce and initAgent members of
the abstract ConversationManager), so an npm install that reshapes one of
them fails a test rather than drifting silently. We will not break the integration API across minor releases of
@neo4j-labs/agent-memory without a CHANGELOG callout.
Neo4jMemoryStore needs @strands-agents/sdk >= 1.16.0, the release that
introduced MemoryManager/MemoryStore.
A note on SnapshotStorage vs the unified Storage
Strands 1.16 introduced a unified Storage interface (write/read/delete/
list over opaque Uint8Array values) and marked the snapshot-shaped
{ snapshot: SnapshotStorage } form of SessionManagerConfig.storage
deprecated. Neo4jSessionStorage deliberately stays on the snapshot form,
which Strands still accepts: a byte-blob backend would reduce a NAMS
conversation to one opaque value, losing entity extraction, message search and
graph traversal over the transcript — the reasons to persist a session into a
graph at all. If Strands removes the snapshot form, the integration will move to
a unified Storage that keeps the hybrid message mapping behind it.
Verify the integration
In typescript/examples/strands/, run npm run lint and npm test after the
README’s SDK-build/install sequence. The suite checks session replay, context,
reasoning and memory-store behavior offline. A live test must use your selected
Strands/model versions and inspect returned conversation ids.