Backends: Bolt vs NAMS

A conceptual look at the two storage backends MemoryClient supports — when to pick which, what’s different at the operational layer, and what trade-offs each makes.

The same MemoryClient API runs against two completely different storage backends: direct-bolt-to-Neo4j (the historical default) and the hosted Neo4j Agent Memory Service (NAMS). For task-oriented setup, see Use NAMS. For porting, see Migrate to NAMS.

TL;DR

Concern Bolt (direct Neo4j) NAMS (hosted service)

You provision

Neo4j cluster (or Aura)

Just an API key

Ops burden

You own indexes, backups, upgrades

Managed for you

Schema control

Full (you can extend models)

Server-managed

Custom Cypher

Read + write

Read-only (via client.query.cypher)

Geospatial features

Yes (Point properties, GDS)

Not exposed

Multi-tenancy

user_identifier kwarg

user_identifier (sent as userId) + workspace via API key or workspace_id

Workspace addressing

n/a

workspace_idX-Workspace-Id header (for header-scoped deployments)

Ontologies

NotSupportedError (use SchemaModel.CUSTOM)

client.ontology — typed, versioned, validated domain schemas

Embeddings / extraction

Client-side (your config)

Server-side (managed)

Latency

In-cluster (microseconds)

HTTPS round-trip (tens of ms)

When to choose which

Choose bolt when:

  • You already operate Neo4j (Aura or self-hosted) and want first-class access.

  • You need write-Cypher for custom data ingestion alongside agent memory.

  • You’re using bolt-only features: GDS algorithms, custom indexes, adopt_existing_graph to layer agent memory onto an existing knowledge graph.

  • You need geospatial queries (Point properties, distance search).

  • Air-gapped deployments — no outbound HTTPS to a hosted service.

Choose NAMS when:

  • You don’t want to run Neo4j. A managed REST API is enough.

  • You’re building a quick prototype and an API key is faster than spinning up Aura.

  • You don’t need write-Cypher — the high-level client.short_term / client.long_term / client.reasoning surface covers your use case.

  • You want server-managed embedding, extraction, and deduplication.

Use both at once

MemoryClient is backend-monomorphic per-instance — one client holds one backend. But you can construct two clients in the same process if you want hybrid setups (e.g. bolt for domain data + NAMS for agent memory).

Feature matrix

This is the canonical capability reference. Backend-sensitive how-to pages link here from their Available on NAMS badge.

"Tiers" are about conformance, not access. The Bronze / Silver / Gold / Platinum labels used elsewhere in the project are agent-memory-tck cross-language conformance levels — they describe how much of the memory model a client SDK implements, not a NAMS pricing or access tier. Every capability marked Yes below is available to any authenticated NAMS workspace. For real usage limits, see NAMS limits & behavior.

Capability Bolt NAMS

client.short_term.add_message / search_messages

Yes

Yes

client.short_term.list_sessions / list_conversations

Yes

Bolt-only / Yes

client.short_term.create_conversation / bulk_add_messages

(synthesized)

Yes

client.short_term.get_observations / get_reflections

No

Yes

client.long_term.add_entity / search_entities

Yes (with dedup)

Yes (server-side dedup)

client.long_term.set_entity_feedback / get_entity_history

No

Yes

client.long_term.get_entity_provenance

Yes

Yes

client.long_term.geocode_locations / search_locations_near

Yes

No

client.long_term.find_potential_duplicates / merge_duplicate_entities

Yes

(handled server-side)

client.reasoning.start_trace / add_step / record_tool_call

Yes

Yes

client.reasoning.get_tool_stats

Yes (dict)

No (use client.query.cypher)

client.users (UserMemory)

Yes

No (use user_identifier= on calls)

client.buffered (BufferedWriter)

Yes

No (NAMS commits synchronously)

client.consolidation (dedupe / archival jobs)

Yes

No (server-managed)

client.eval (evaluation harness)

Yes

Yes (audit dimension is bolt-only)

client.query.cypher (read-only)

Yes

Yes

client.graph (raw Neo4jClient)

Yes (deprecated, removal v0.6.0)

No (use client.query.cypher)

client.schema.adopt_existing_graph

Yes

No (server-managed schema)

Agent Skills — distil memory into portable Agent Skills (Preview)

No

REST + MCP only, no SDK method (see Skills API)

MCP server

Yes (16 tools)

Yes (16 tools + 4 NAMS extras)

LangChain, LlamaIndex, CrewAI, OpenAI Agents, Microsoft Agent, Google ADK

Yes

Yes (transparent — no code changes)

Pydantic AI

Yes

Yes + nams_memory_tools() for NAMS extras

Strands / AgentCore (AWS)

Yes

Yes + nams_context_graph_tools() for NAMS extras

Tenancy & scoping

"How do I isolate one tenant’s memory from another’s?" The answer has two independent axes, and which ones you use depends on the backend.

Axis Bolt (direct Neo4j) NAMS (hosted)

User — separate one end-user’s conversations, entities, and preferences from another’s

user_identifier= kwarg on add_message, start_trace, add_entity, … (requires memory.multi_tenant=True). First-class :User nodes via client.users.

user_identifier= on the same calls, forwarded to the service as userId. (client.users is not available — scoping is per-call only.)

Workspace — separate whole projects / customers / environments, each with its own graph

Not a built-in concept — run separate databases or separate MemoryClient instances.

The unit of isolation. A workspace key is permanently bound to one workspace; an admin key selects the workspace per-request via workspace_id → the X-Workspace-Id header. Different key/workspace = different graph. See key categories.

In short: on bolt you scope by user_identifier (and operate separate databases for hard isolation); on NAMS the workspace (key- or header-bound) is the hard boundary and user_identifier sub-divides users within it. There is no workspace_id argument on memory calls — workspace is established at connect time, not per operation.

For the task-oriented walkthrough see Multi-Tenant Memory (bolt) and Use NAMS (workspace setup). Sharing one workspace across several agents is covered in Cross-Agent Memory Sharing.

Operational model

Bolt: you own the database

The driver opens a persistent Bolt connection pool to a Neo4j instance. Reads and writes are issued as Cypher statements; schema setup (constraints, vector indexes, point indexes) is run on connect(). Memory lives entirely inside Neo4j — your backups, your upgrades, your ops.

NAMS: hosted REST API

MemoryClient opens an httpx.AsyncClient to the NAMS endpoint. Every operation is one HTTP request. NAMS owns the underlying Neo4j cluster; you never touch it directly. Auth is a single long-lived API key (MEMORY_API_KEY), sent as Authorization: Bearer …​.

Wire protocols (NAMS only)

NAMS supports two:

  • REST (POST /v1/conversations/{session_id}/messages etc.) — the hosted service URL ending in /v1.

  • TCK bridge (POST /{snake_case_method}) — used by the conformance test reference implementation. The transport auto-selects based on whether the endpoint URL contains /v\d+.

You can force one or the other with NamsConfig(transport_mode="rest" | "bridge").

Error semantics

Both backends share the same exception hierarchy under MemoryError. NAMS adds five subclasses for HTTP-specific failures:

Exception When raised

TransportError (subclass of ConnectionError)

Network errors and 5xx after retries exhausted

AuthenticationError

401, 403

NotSupportedError

Method unavailable on the active backend (or HTTP 405/501)

RateLimitError

429 after retries exhausted (carries retry_after)

ValidationError

HTTP 400 (carries details)

The retry policy honors Retry-After on 429 and uses exponential backoff for 5xx and network errors.

The unified API contract

Both backends honor four @runtime_checkable Protocols in neo4j_agent_memory.core.protocols:

  • ShortTermProtocoladd_message, get_conversation, search_messages, list_sessions, delete_message, clear_session, get_context, get_conversation_summary, create_conversation, list_conversations, bulk_add_messages, get_observations, get_reflections.

  • LongTermProtocoladd_entity, add_preference, add_fact, add_relationship, search_entities, search_preferences, search_facts, get_entity_by_name, get_related_entities, get_preferences_for, supersede_preference, get_facts_about, get_entity_relationships, get_context, set_entity_feedback, get_entity_history, get_entity_provenance.

  • ReasoningProtocolstart_trace, add_step, record_tool_call, complete_trace, search_steps, get_similar_traces, get_trace, get_trace_with_steps, get_session_traces, list_traces, get_tool_stats, link_trace_to_message, get_context.

  • CypherQueryProtocolcypher(query, params) (read-only).

Code written against these Protocols (or their concrete bolt classes, which structurally satisfy them) is portable. Bolt-specific extras like add_messages_batch, extract_entities_from_session, find_potential_duplicates, geocode_locations live on the bolt classes only and aren’t part of the protocol contract.

What’s not on either backend

Some features are bolt-only by design (the underlying service doesn’t expose them):

  • client.users (first-class User nodes)

  • client.buffered.submit(…​) (background write queue)

  • client.consolidation.* (dedupe / archival jobs)

  • client.schema.adopt_existing_graph() (layering onto a pre-existing graph)

  • Geospatial / Point-property features

  • Audit-edge (:TOUCHED) features

On NAMS these accessors return a _NamsUnsupported sentinel — property access is harmless, but method calls raise NotSupportedError with a workaround hint. See Migrate to NAMS for the full table.

Performance characteristics

Operation Bolt NAMS

Single add_message

~1ms (in-cluster)

~50ms (HTTPS RT)

Bulk add_messages_batch(100)

~10ms

(bolt-only)

bulk_add_messages(100)

(NAMS only)

~80ms

search_messages (vector)

~5-50ms (index size)

~50-100ms

client.query.cypher (small read)

~1ms

~50ms

NAMS is dominated by HTTPS round-trip cost. For high-throughput workloads, bolt is materially faster. For typical chat-agent workloads (one message per turn), the difference is invisible to users.

Migration is one env var away

# Existing bolt code:
async with MemoryClient(MemorySettings(neo4j={"password": "..."})) as client:
    await client.short_term.add_message(...)

Becomes:

import os
os.environ["MEMORY_API_KEY"] = "nams_xxxxx"

# Same code body — backend auto-selects NAMS from env.
async with MemoryClient() as client:
    await client.short_term.add_message(...)

See Migrate to NAMS for the porting guide and full NotSupportedError reference.