How to: integrate with AWS Strands Agents
The @neo4j-labs/agent-memory/integrations/strands subpath plugs into
all three of Strands' orthogonal extension surfaces:
-
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.
A runnable example lives at
clients/typescript/examples/strands.
When to use this
You want any of:
-
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
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.