zep-strands
Add long-term agent memory to Strands Agents via Zep's temporal Context Graph.
ZepMemoryStore implements Strands' MemoryStore interface, so it plugs into MemoryManager for automatic recall (injection + search_memory), server-side extraction (add_messages → Zep threads), and optional on-demand graph search.
Installation
pip install zep-strands
Requires Python 3.11+, strands-agents>=1.45.0, zep-cloud>=4.0.0,<5, and a Zep Cloud API key from app.getzep.com.
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.
Quick start
from strands import Agent
from strands.memory import MemoryManager
from zep_cloud.client import AsyncZep
from zep_strands import ZepMemoryStore, create_thread, create_user
zep = AsyncZep(api_key="your-api-key")
user = await create_user(
zep,
first_name="Jane",
last_name="Smith",
email="jane@example.com",
)
thread = await create_thread(zep, user_uuid=user.uuid_)
store = ZepMemoryStore(
zep_client=zep,
user_uuid=user.uuid_,
thread_uuid=thread.uuid_,
first_name="Jane",
last_name="Smith",
writable=True,
extraction=True, # server-side via add_messages
)
agent = Agent(
system_prompt="You are a helpful assistant with long-term memory.",
memory_manager=MemoryManager(stores=[store]),
)
With no further configuration, the manager injects relevant Zep context before each user turn and runs server-side extraction on Strands' default cadence (every 5 turns). That means conversation turns are buffered and only sent to Zep when the trigger fires (or when you call memory_manager.flush()), so graph building is delayed relative to turn-by-turn persistence — and Zep's own ingestion remains asynchronous after messages arrive. Enable add_tool_config=True on the manager to also let the model call add_memory.
How it works
| Strands hook | Zep call | Purpose |
|---|---|---|
MemoryStore.search |
graph.get_context or graph.search_* |
Recall for injection and the search_memory tool |
MemoryStore.add_messages |
thread.add_messages |
Server-side extraction from conversation turns |
MemoryStore.add |
graph.episode.add |
Single-fact writes (add_memory tool / programmatic) |
First search / write in user-graph mode |
user.get |
One read of the user record for the graph UUID, then cached |
MemoryStore.get_tools |
create_zep_search_tool |
Optional on-demand graph search (when enabled) |
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.
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 pending turns persisted before shutdown). - Or pass an every-turn trigger if you need messages sent to Zep more often:
from strands.memory.extraction.triggers import InvocationTrigger
from strands.memory.extraction.types import ExtractionConfig
store = ZepMemoryStore(
zep_client=zep,
user_uuid=user_uuid,
thread_uuid=thread_uuid,
writable=True,
extraction=ExtractionConfig(trigger=InvocationTrigger()), # after every turn
)
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 standalone graphs or read-only stores.
Scoping modes
User graph (default for conversational agents) — pass user_uuid and thread_uuid:
ZepMemoryStore(zep_client=zep, user_uuid=user_uuid, thread_uuid=thread_uuid, ...)
Standalone graph (shared / domain knowledge) — pass graph_uuid. Supports search and add only (no add_messages):
ZepMemoryStore(zep_client=zep, graph_uuid=graph_uuid, writable=True, extraction=False)
Provide exactly one of user_uuid or graph_uuid.
Provisioning users and threads
create_user and create_thread create a resource one time and return the object that Zep created. Read uuid_ from the response and store it in your own database; failures raise.
The store never creates a resource and never resolves a name at run time, so you must create the user and the thread out of band before the first turn.
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 and raise RuntimeError: ... is bound to a different event loop for any connection you had already opened. Deferring to first use keeps every Zep call on the agent's own loop, so one client can safely be shared between your application code and the store.
from zep_strands import create_thread, create_user
user = await create_user(
zep,
first_name="Jane",
last_name="Smith",
on_created=configure_ontology, # optional async hook, receives the user UUID
)
thread = await create_thread(zep, user_uuid=user.uuid_)
# Keep user.uuid_ and thread.uuid_ in your own database.
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:
store = ZepMemoryStore(
zep_client=zep,
user_uuid=user_uuid,
thread_uuid=thread_uuid,
search_scope="edges",
search_filters={"edge_types": ["PREFERS"]},
)
On-demand graph search tool
Set expose_search_tool=True to register a model-callable zep_search tool via get_tools():
store = ZepMemoryStore(
zep_client=zep,
user_uuid=user_uuid,
thread_uuid=thread_uuid,
expose_search_tool=True,
search_pinned_params={"scope": "auto", "limit": 10},
)
Or build the tool yourself:
from zep_strands import create_zep_search_tool
tool = create_zep_search_tool(
zep_client=zep,
user_uuid=user_uuid,
search_pinned_params={"scope": "edges"},
)
agent = Agent(tools=[tool])
Pin-or-expose. Every search parameter (scope, reranker, limit, mmr_lambda, center_node_uuid) is model-exposed by default. search_pinned_params fixes a value and hides it; search_hidden_params hides without pinning (Zep's default applies). search_filters and bfs_origin_node_uuids are always constructor-only.
Zep v4 accepts only some rerankers for each scope. The tool schema shows only the rerankers that the scope supports, and an invalid pair gives the model an error that names the valid values. node_distance needs center_node_uuid, and episode_mentions needs episode_uuids in search_filters. When the reranker is mmr and no mmr_lambda is given, the tool sends 0.5. The auto scope takes no reranker: a reranker with scope="auto" gives the model an error, and the tool does not call Zep. The reranker parameter has no schema default. When the model gives no reranker for a scope other than auto, the tool sends rrf. An invalid pinned pair (including a pinned reranker with scope="auto"), or an invalid search_scope and search_reranker pair on ZepMemoryStore, raises a ValueError.
Writing facts
# Text fact into the user graph
await store.add("Prefers aisle seats", metadata={"source": "prefs"})
# JSON payload
await store.add('{"plan": "premium"}', metadata={"type": "json"})
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 episode 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).
Error handling
Zep SDK errors propagate out of the store methods deliberately: in Strands the framework owns failure isolation, and swallowing them breaks it.
| Path | Framework behavior on a raise |
|---|---|
search |
MemoryManager.search logs and skips the failing store; injection additionally fails open, so the turn proceeds without memory |
add |
Surfaced as AggregateMemoryError so a failed write is never silent |
add_messages |
ExtractionCoordinator catches it and rolls back its high-water mark so the batch retries |
That last one 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 an error string — a raw tool has no framework layer above it.
Identity
Pass real names (first_name, last_name, email) to create_user so Zep anchors the user graph node. The store takes first_name and last_name for the display names on persisted messages; they default to the user's full name and to "Assistant".
One store instance is bound to one user_uuid/thread_uuid (or graph_uuid) at construction.
Features
- Native Strands
MemoryStore— works withMemoryManagerinjection, tools, and extraction - Server-side extraction via Zep threads (
add_messages); default cadence every 5 turns (delayed graph building) - Fail-fast validation when
extractionis enabled without a writable user/thread - Whole-user-graph recall across threads
- Standalone-graph mode for shared knowledge
- Optional pin-or-expose
zep_searchtool create_user/create_threadprovisioning helpers that give back the server UUIDs- Message and graph payload truncation with length-only warnings
Configuration
export ZEP_API_KEY="your-zep-api-key"
Examples
See examples/basic_agent.py for an end-to-end multi-thread recall demo. Setup steps are in SETUP.md.
Development
cd integrations/strands/python
make install # uv sync --extra dev
make all # format + lint + type-check + test
Requirements
- Python 3.11+
strands-agents>=1.45.0zep-cloud>=4.0.0,<5
Support
License
Apache 2.0 — see the repository LICENSE.
Metadata
Release files for zep-strands 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| zep_strands-1.0.0.tar.gz | 38.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zep_strands-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 64.4 kB
Release files / zep_strands-1.0.0.tar.gz
| Download URL | zep_strands-1.0.0.tar.gz |
|---|---|
| Size | 38.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
767e6a90581a64edaa8707137413c837feabcf08b41f27ed4422f8394211f677
|
|
BLAKE2b-256 checksum How to use checksums |
36d88fe572f179639c2f6f3dd72b6cb7fd3c652d844f043d1009206480c45c24
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / zep_strands-1.0.0-py3-none-any.whl
| Download URL | zep_strands-1.0.0-py3-none-any.whl |
|---|---|
| Size | 25.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aa0480cef7c5082fadbb97a7a4cff9531e89a7d939ac7243c6e62bdb2800ba61
|
|
BLAKE2b-256 checksum How to use checksums |
4a51a8d3f60e5a7ca7174205bb8c4e3d259fca61dd7d99c174af83fc9e067e47
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log