How to: 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 isstrands_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
clients/typescript/examples/strands.
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.
| Option | Meaning |
|---|---|
|
Required. Identifies the store to |
|
Exactly one. |
|
Shown in tool descriptions. Defaults to a generic "entities extracted from conversations" line. |
|
Default result cap when a caller doesn’t pass a per-call limit. |
|
Defaults to |
|
|
|
Explicit write sink. Omit to use a deterministic, name-derived sink conversation. |
|
Scopes the sink conversation: it names the sink, is recorded on it, and filters the scan that finds it. Nothing else is narrowed — |
|
Defaults to |
|
Defaults to |
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 (`bulkAddMessages’s 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 one page of 1000 conversations.
listConversationsexposes no cursor, so auserIdwith more than that many conversations 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();
const conv = await memory.shortTerm.createConversation({ userId: "alice" });
const agent = new Agent({
...await connectMemoryToAgent(memory, { conversationId: conv.id }),
model: new OpenAIModel({ modelId: "gpt-4o-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.
Individual pieces (advanced)
For finer control:
import {
SessionManager,
SlidingWindowConversationManager,
} 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,
});
const agent = new Agent({ sessionManager, 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));
The chat UI in demo/agents/spool/ doesn’t fetch raw conversation
messages (it streams from agent.invoke directly) so it doesn’t need
to filter; consumers that DO fetch raw messages must.
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
We test against @strands-agents/sdk@^1.2.0. Strands' v1 API is stable
under semver — the integration should track v1 minor releases without
adjustment. 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.6.0, the release that
introduced MemoryManager/MemoryStore.