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. MemoryManager searches 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 as Message graph nodes; non-message snapshot state (Strands' data minus messages, plus appData, plus manifests) rides on synthetic role: "user" marker messages whose content is strands_state:{base64-JSON}.

  • ConversationManager — three-tier context (reflections + observations from getContext()) is prepended to every model invocation, on top of a configurable inner manager (defaults to SlidingWindowConversationManager).

  • HookRegistry eventsBeforeInvocation, AfterInvocation, BeforeToolCall, and AfterToolCall are wired to the reasoning subclient so every agent turn becomes a queryable ReasoningStep with 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:

  1. set extraction on the store,

  2. pair it with Neo4jSessionStorage / connectMemoryToAgent (the recommended shape, below), or

  3. set addToolConfig: true on 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

name

Required. Identifies the store to MemoryManager (search/add tool targeting).

client / clientOptions

Exactly one. client borrows a live MemoryClient (left open on close()); clientOptions builds 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 to false (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 — searchEntities takes no user filter, and add({ kind: "entity" }) reaches the workspace-wide addEntity. Not tenant isolation.

includeEntities

Defaults to true. false turns off automatic recall only; set graphTools: false as well to stop the model reading the graph through the tool.

graphTools

Defaults to true. Exposes the store’s get_entity_graph tool from getTools().

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 (requires subject/predicate/object in 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. listConversations exposes no cursor, so a userId with more than that many conversations can push its own sink out of view, and the store creates a second one. Pass an explicit conversationId to 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/name can 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

Message nodes attached to the Conversation

Agent state (Snapshot.data, non-message fields)

synthetic role: "user" message with content strands_state:{base64-JSON-blob}

Application state (Snapshot.appData)

inside the same synthetic message’s content blob

Tool calls

ToolCall nodes attached to a ReasoningStep

Per-turn reasoning

ReasoningStep nodes attached to the Conversation

Manifests (snapshot metadata)

synthetic role: "user" message with content strands_manifest:{base64-JSON-blob}

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.

Auth errors propagate

SnapshotStorage failures are not best-effort. If the integration can’t read or write the snapshot blob, Strands' own retry semantics kick in. An auth failure surfaces as AuthenticationError from saveSnapshot / loadSnapshot.

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.