Strands integration
Add long-term agent memory to Strands
Strands agents using Zep gain long-term memory backed by a temporal knowledge graph. The zep-strands package implements Strands’ MemoryStore interface so Zep plugs into MemoryManager for context injection, server-side extraction, and optional on-demand graph search.
Zep context can include content that your users, documents, or tools supplied. A system or developer message gives that content higher instruction priority than ordinary input. Some convenience integrations use system-message injection. Use direct SDK retrieval or an actual retrieval tool call unless all stored content is application-authored and trusted. Follow Memory security best practices for provider-specific placement.
To build an agent that plans its retrieval and uses several Zep tools, read Build an Agent with Zep. The guide shows how to add domain knowledge, design tools, and evaluate the agent.
Core benefits
- Native Strands
MemoryStore: Works withMemoryManagerfor injection, built-in memory tools, and automatic extraction - Context injection for trusted content:
MemoryManagercan inject application-authored context before each user turn - Batched server-side extraction: Conversation turns are buffered and posted to Zep via
thread.add_messageson Strands’ default cadence (every 5 turns) - Whole-user-graph recall: Context is fused across all of a user’s threads, so a new conversation still recalls earlier facts
- Pin-or-expose graph search:
expose_search_tool/create_zep_search_tooladd an on-demand tool over the Zep graph search methods, with every search parameter model-exposed by default or pinned/hidden per deployment - Context Graph mode: Scope a store to a shared
graph_uuidfor domain knowledge (search and add only) - Out-of-band provisioning:
create_userandcreate_threadcreate the resources up front and return the UUID that the server gives for each one - Framework-owned failure isolation: Zep SDK errors propagate from store methods so Strands can skip, surface, or retry correctly
How it works
The integration ships one main class, ZepMemoryStore, which implements Strands’ MemoryStore Protocol and plugs into MemoryManager(stores=[store]). The manager owns injection, the extraction loop, and two built-in tools: search_memory (registered by default via search_tool_config=True; set False to disable) and add_memory (opt-in via add_tool_config=True). The store maps each hook onto Zep:
Use search_memory or create_zep_search_tool for context that can contain end-user or third-party data. The provider can then transport the data as an actual tool result.
Context comes from the whole user graph; the thread only scopes relevance and records the conversation. A new thread for the same user still recalls earlier facts.
ZepMemoryStore.initialize() deliberately makes no Zep calls. Agent.__init__ is synchronous, so Strands runs that hook on a throwaway event loop in a worker thread; calling Zep there would drive your AsyncZep client from a second event loop. Deferring to first use keeps every Zep call on the agent’s own loop.
Zep v4 addresses a user, a thread, and a graph by a server-generated UUID. A v4 create operation accepts no client-chosen name. Create each resource one time, keep the UUID from the response in your own database, and give the UUID to the store. The store does not resolve a name at run time.
Installation
The package depends on strands-agents and zep-cloud. The example below also uses a model provider:
Requires Python 3.11+, strands-agents>=1.45.0, zep-cloud>=4.0.0,<5, and a Zep Cloud API key. Get your API key from app.getzep.com.
Set up your environment variables:
Usage
Provision the Zep user and thread, construct a ZepMemoryStore, and pass it to MemoryManager:
Memory is scoped per ZepMemoryStore instance to one user_uuid and thread_uuid (or one graph_uuid). For a multi-user application, construct one store per user or conversation, passing real names so Zep can resolve the user’s identity node in the graph.
For a trusted-only deployment, the manager injects relevant Zep context before each user turn. It also registers search_memory and runs server-side extraction on Strands’ default cadence. Use tool retrieval without automatic injection for end-user or third-party content.
Automatic extraction and delayed graph building
extraction=True (the default when the store is writable with user_uuid + thread_uuid) opts into Strands’ automatic extraction loop. With the manager’s defaults that means:
- Conversation turns are buffered in the manager.
- Every 5 turns, Strands calls
add_messages, which posts the batch to Zep viathread.add_messages. - Zep then processes the batch asynchronously into the user graph.
Until step 2 runs, nothing has been sent to Zep, so the graph does not grow turn-by-turn. After step 2, facts are still not instantly searchable (Zep ingestion is async). Plan for both delays:
- Call
await memory_manager.flush()at session boundaries (required afterinvoke_async/stream_asyncif you need queued turns persisted before shutdown). - Or pass an every-turn trigger if you need messages sent to Zep more often:
extraction=True (or an ExtractionConfig) requires writable user-graph mode with both user_uuid and thread_uuid. Construction raises ValueError otherwise — use extraction=False for Context Graphs or read-only stores.
Scoping modes
User graph (default for conversational agents) — pass user_uuid and thread_uuid:
Context Graph (shared / domain knowledge) — pass graph_uuid. Supports search and add only (no add_messages):
Provide exactly one of user_uuid or graph_uuid.
Search and injection
By default search_scope="auto", so injection receives Zep’s assembled Context Block as a single MemoryEntry. Pin a scoped search when you want discrete facts:
On-demand graph search
Beyond automatic injection and the manager’s built-in search_memory tool, create_zep_search_tool returns a separate model-callable Strands tool over the Zep graph search methods. The model decides when to look up specific facts, entities, or prior episodes. By default it searches the graph of the given user; pass graph_uuid=... to target a shared Context Graph instead.
search_memory is on by default (search_tool_config=True) and calls store.search, so a Zep failure is logged and that store is skipped. zep_search (expose_search_tool=True) is opt-in, exposes pin-or-expose search parameters to the model, and on failure returns "Graph search failed." rather than raising. Prefer one model-callable search path unless you intentionally want both.
The easiest way to use zep_search is expose_search_tool=True on ZepMemoryStore, which registers the tool via get_tools():
With this configuration, the model sees the un-pinned parameters (reranker, mmr_lambda, center_node_uuid). scope and limit are hidden from the schema and sent with the pinned values.
Every search parameter (scope, reranker, limit, mmr_lambda, center_node_uuid) is exposed to the model in the tool’s schema by default, with documented defaults. Two options override this per deployment: search_pinned_params fixes a parameter to a constant value and hides it from the schema, and search_hidden_params hides a parameter without pinning it, so Zep’s server-side default applies. search_filters and bfs_origin_node_uuids are constructor-only — their complex shapes are not exposed to the model.
Zep accepts only some rerankers for each scope. The auto scope takes no reranker.
The tool applies these rules before it sends a request:
- When
scopeis pinned, the schema shows only the rerankers that the scope supports. Whenrerankeris pinned, the schema shows only the scopes that support it, and does not showauto. node_distanceneedscenter_node_uuid. The schema showsnode_distanceonly when the model can givecenter_node_uuidor you pin it.episode_mentionsneedsepisode_uuidsinsearch_filters. The schema showsepisode_mentionsonly when you setepisode_uuids.- When the reranker is
mmrand nommr_lambdais given, the tool sendsmmr_lambda=0.5. - The
rerankerparameter has no schema default. When the model gives no reranker for a scope other thanauto, the tool sendsrrf. - When the model sends a reranker with
scope="auto", the tool sends no request. It returns an error that tells the model to search again with no reranker or with another scope. - When the model sends a scope and reranker pair that Zep does not accept, the tool sends no request. It returns an error that names the setting and the valid values, so the model can search again with valid settings.
An invalid pinned pair, including a pinned reranker with scope="auto", raises a ValueError when you create the tool. The same validation applies to search_scope and search_reranker on ZepMemoryStore: an invalid pair, including a search_reranker with search_scope="auto", raises a ValueError at construction.
The standalone factory takes the same pin-or-expose options:
Model-exposed search parameters (when not pinned or hidden), with their defaults. These are the tool’s defaults and are independent of the store’s search_scope (which defaults to "auto" for injection):
Writing facts
Write facts programmatically with store.add, or enable add_tool_config=True on MemoryManager so the model can call add_memory:
metadata["type"] selects the Zep data type (text default, json, or message). Remaining metadata keys are forwarded as episode metadata.
Oversized text/message payloads are truncated to Zep’s graph.episode.add limit with a warning. Oversized json is rejected with a ValueError instead — slicing JSON strips its closing syntax, so a truncated document would just be rejected by Zep. Split large JSON into smaller documents before adding; see chunking large documents.
Provisioning
create_user and create_thread create the Zep user and thread out-of-band, before the first turn. Each helper returns the object that Zep created. Read uuid_ from the response and keep that value in your own database:
Both helpers propagate a Zep failure. Use the on_created hook (a UserSetupHook) to configure per-user resources such as a custom ontology, custom extraction instructions, or user summary instructions one time; see customizing graph structure for the available options. If on_created raises, that exception propagates even though the user was created, so make the hook idempotent.
The store never creates a resource, so the application creates the user and the thread before the first turn.
Migrate from v3
To migrate from Zep v3, follow the v3 to v4 migration guide for the general procedure. zep-strands 1.0.0 has these package-specific changes:
- The package requires the Zep v4 SDK (
zep-cloud>=4.0.0,<5). ZepMemoryStore:user_id,thread_id, andgraph_idare renamed touser_uuid,thread_uuid, andgraph_uuid. Give the stored user UUID and thread UUID in user-graph mode, or the stored graph UUID in Context Graph mode.create_zep_search_tool:user_idandgraph_idare renamed touser_uuidandgraph_uuid. Give the stored user UUID or graph UUID.ensure_userandensure_threadare removed. For a new user or thread, usecreate_userandcreate_thread, and readuuid_from each returned object.create_userhas nouser_idparameter.create_threadtakesuser_uuidinstead ofthread_idanduser_id. Theon_createdhook ofcreate_userreceives the user UUID.ZepMemoryStoredoes not create a user or a thread. Itsemailandon_user_createdparameters are removed. Giveemailandon_createdtocreate_user.
For an existing V3 user, thread, or graph, do not call create_user, create_thread, or graph.create. Get its UUID one time with the backfill in the migration guide, and store it.
Error handling
Zep SDK errors propagate out of the store methods deliberately: in Strands the framework owns failure isolation, and swallowing them breaks it.
That last path matters most: returning None after swallowing an error would be read as success, advancing the mark and discarding those messages permanently. This matches the SDK’s own vended stores, which raise rather than degrade.
The one exception is the model-callable zep_search tool, which catches Zep errors and returns "Graph search failed." — a raw tool has no framework layer above it.
Configuration options
ZepMemoryStore accepts:
Best practices
- Pass real names so Zep can anchor and resolve the user’s identity node in the graph
- One store per user/conversation — memory is scoped to a single
user_uuid/thread_uuid(orgraph_uuid) - Reuse a single
AsyncZepclient across requests; the caller owns its lifecycle - Provision up front in onboarding flows with
create_user/create_thread, and keep each UUID in your own database - Flush at session boundaries — call
await memory_manager.flush()afterinvoke_async/stream_asyncso buffered turns reach Zep before shutdown - Allow time for indexing — Zep extracts knowledge asynchronously, so facts from a turn are not instantly retrievable (and with the default 5-turn cadence, messages may not have been sent yet)
Next steps
- Explore customizing graph structure for advanced knowledge organization
- Learn about searching the graph and how to tune search
- See code examples for additional patterns