Zep: Give Your AI Agents Long-Term Memory with a Temporal Knowledge Graph
Have you ever chatted with a bot that forgets everything about you the moment you open a new conversation? You spend ages explaining your preferences, your project names, all the important technical details, and the next day it greets you like a total stranger. Super frustrating, right? This happens because, by default, Large Language Models (LLMs) are stateless. They only know what lives inside a single context window. Once a conversation gets long or a new session starts, all that memory is gone.
In this tutorial I want to introduce you to Zep, a memory layer for AI agents that, in my opinion, cleans up the way we give agents "memory" in a really elegant way. It does not just dump raw chat history into a database. Instead, Zep builds something called a temporal knowledge graph. It extracts important facts from your conversations, arranges them into a graph of relationships, and records when each fact became valid and when it changed. When your agent needs context, Zep hands back a relevant summary you can inject straight into the prompt. Practical, and token-efficient.
I will walk you through this from the ground up: why memory matters for agents, how to sign up for Zep Cloud and grab an API key, how to install the SDK, how to create users and sessions, add messages, retrieve memory and context, search the graph, and finally wire it all into a simple chatbot loop that actually remembers you. Every example uses Python so it is easy to follow along. Let us dive in.
Introduction
Why memory matters for AI agents
Picture having a human personal assistant. Their main value is not only that they can answer questions, but that they remember your context. They know you are allergic to peanuts, they know you are working on a database migration project, they know you prefer answers that get straight to the point. An assistant who remembers these things feels far smarter and more personal than one you have to re-brief every single day.
The AI agents we build are the same. Without memory, our agents are just reactive and generic. With long-term memory, an agent can:
- Personalize responses based on interaction history.
- Continue multi-session tasks without the user re-explaining context.
- Make more consistent decisions because it knows prior preferences and facts.
- Reduce token cost, because we do not have to drag the entire long transcript into every request.
That last point is often underestimated. If you are naive and send the whole conversation history to the LLM every single time, your token cost explodes and latency grows as the conversation gets longer. On top of that, there is a hard context window limit. So we need a smart system that picks what is relevant instead of hoarding everything.
What Zep is and how it differs from plain history storage
The most primitive way to give an agent memory is to store the entire array of messages in a database and reload all of them every conversation. Easy, but it does not scale. A second, fairly popular approach is RAG over chat history: we embed each message, store it in a vector store, then retrieve similar messages. Better, but it still pulls raw conversation chunks without understanding which facts actually matter.
Zep takes a smarter approach. Behind the scenes, Zep runs a process it calls extraction and knowledge graph construction. Every time you add a message to Zep, the system will:
The word "temporal" is the key here. The world changes, user preferences change, project status changes. Zep stores this time dimension, so your agent not only knows the latest fact but can also trace the history of how facts changed. That is what sets Zep apart from a plain key-value store or ordinary vector search.
When we ask Zep for the context of a session, it returns a concise block of text containing the relevant facts, ready to paste into the system prompt. So our agent gets dense, relevant "memory" without having to read through thousands of tokens of transcript.
Core concepts you need to understand
Before we start coding, here are a few terms that will show up a lot:
- User: the entity representing a person or account. All memory can be tied to a single user.
- Session/Thread: one conversation thread belonging to a user. A user can have many sessions.
- Message: a single message in a thread, with a role (user or assistant) and content.
- Memory/Context: the context summary Zep produces to inject into the prompt.
- Graph: the collection of facts (edges) and entities (nodes) we can search.
Alright, concepts locked in. Now let us get into the technical part.
Installation
Sign up for Zep Cloud and grab an API key
Zep has two options: self-hosted (open source) and Zep Cloud (managed). For this tutorial I use Zep Cloud because it is the fastest way to get started and we do not need to run our own graph database infrastructure. The steps are simple:
This API key is what we will use to authenticate from our Python code. Never hardcode this key into code you commit to Git. I will show you the safe way using an environment variable shortly.
Install the library
Zep has an official Python SDK. Installing it is a single line:
pip install zep-cloud
I recommend creating a virtual environment first to keep things tidy and avoid clashing with other project dependencies:
python -m venv venv
source venv/bin/activate # on Windows: venv\Scripts\activate
pip install zep-cloud python-dotenv
I also install python-dotenv to easily read the API key from a .env file. Create a .env file in your project folder:
ZEPAPIKEY=yourzepkeyhere
And do not forget to add .env to your .gitignore so it does not get committed to the repository.
Initialize the client
Now let us test the connection. Create a file called zepsetup.py:
import os
from dotenv import loaddotenv
from zepcloud.client import Zep
loaddotenv()
client = Zep(apikey=os.environ.get("ZEPAPIKEY"))
print("Zep client initialized successfully")
If you run it and there is no error, the library is installed correctly and the key is being read. Zep also offers an async version via AsyncZep if you build an asyncio-based application. For this tutorial we focus on the synchronous version first to keep it easy to digest.
One important note: the Zep API sometimes evolves between major versions. The examples in this tutorial use common and stable patterns, but if you find a method name slightly different, always check the official documentation for the version you installed. A quick way to see the available resources:
print([m for m in dir(client) if not m.startswith("")])
That will show the main resources like user, thread, graph, and so on.
Basic Usage
Create a user
The first logical step is to create a user. This user is the container for all of a specific person's memory. We give it a unique userid, for example an ID from our own system, plus optional metadata like email and name.
import os
from dotenv import loaddotenv
from zepcloud.client import Zep
loaddotenv()
client = Zep(apikey=os.environ.get("ZEPAPIKEY"))
userid = "user-ruby-001"
client.user.add(
userid=userid,
email="ruby@example.com",
firstname="Ruby",
lastname="Abdullah",
metadata={"plan": "pro", "language": "en"},
)
print(f"User {userid} created successfully")
The userid should be deterministic and consistent from your system, so you can easily pull back the memory of the same person. Metadata is free-form, fill it with anything relevant, for example subscription tier or language preference.
To read the user data back:
user = client.user.get(userid="user-ruby-001")
print(user.firstname, user.email, user.metadata)
Create a session or thread
After creating a user, we create a conversation thread. Think of one thread as one conversation thread. You can create a new thread per daily session, or a single long thread for one topic. The important thing is that each thread is tied to a user.
import uuid
threadid = f"thread-{uuid.uuid4().hex[:8]}"
client.thread.create(
threadid=threadid,
userid="user-ruby-001",
)
print(f"Thread {threadid} created for user-ruby-001")
I use uuid so the threadid is unique. In a real application, this threadid is usually stored in your own database, tied to a user's chat session.
Add messages to the thread
Now this is the heart of the interaction. Every time the user speaks or the assistant replies, we save the message to Zep. Zep stores the raw history while also kicking off fact extraction behind the scenes.
from zepcloud.types import Message
messages = [
Message(
role="user",
name="Ruby",
content="Hi, I'm Ruby. I'm working on a migration from MySQL to PostgreSQL.",
),
Message(
role="assistant",
name="Assistant",
content="Hi Ruby, happy to help with your database migration project.",
),
]
client.thread.add
messages(
threadid=threadid,
messages=messages,
)
print("Messages added successfully")
Notice each message has a role (user or assistant) and content. The name field is optional but useful for marking who is speaking. After you add messages like this, Zep starts extracting facts, for example "Ruby is working on a MySQL to PostgreSQL migration". This fact is what we can pull back later.
Keep in mind graph extraction is an asynchronous process on the Zep server side. So it sometimes takes a few seconds before new facts appear in graph search results. Raw message history, however, is readable immediately.
Retrieve memory and context
Now the most useful part: pulling context to inject into the LLM prompt. Zep provides a method to retrieve the context summary of a thread. The result is already a block of text ready to paste into the system prompt.
memory = client.thread.getusercontext(threadid=threadid)
print(memory.context)
The memory.context contains a dense summary of relevant facts about the user and the conversation. Its output looks roughly like a structured description: who the user is, which facts are valid, which preferences are recorded. This text block is our ammunition for building an agent that "remembers".
If you want to retrieve the raw messages from a thread, there is usually a get method that returns a list of messages along with metadata:
threaddata = client.thread.get(threadid=threadid)
for msg in thread
data.messages:
print(f"{msg.role}: {msg.content}")
So there are two levels: the raw level (list of messages) and the digested context level (fact summary). To feed the LLM, we almost always use the context level because it is denser and more relevant.
Wire it into a simple chatbot
Let us combine everything into a single chatbot loop that actually remembers the user. In this example I use OpenAI as the LLM, but the concept is the same for any provider. The flow: pull context from Zep, paste it into the system prompt, call the LLM, then save the user message and the assistant reply back to Zep.
import os
from dotenv import loaddotenv
from zepcloud.client import Zep
from zepcloud.types import Message
from openai import OpenAI
loaddotenv()
zep = Zep(apikey=os.environ.get("ZEPAPIKEY"))
oai = OpenAI(apikey=os.environ.get("OPENAIAPIKEY"))
USERID = "user-ruby-001"
THREADID = "thread-chatbot-demo"
def ensuresetup():
try:
zep.user.add(userid=USERID, firstname="Ruby")
except Exception:
pass
try:
zep.thread.create(threadid=THREADID, userid=USERID)
except Exception:
pass
def chat(userinput: str) -> str:
# 1. Pull memory context from Zep
memory = zep.thread.getusercontext(threadid=THREADID)
contextblock = memory.context or "No prior context yet."
systemprompt = (
"You are a friendly assistant that remembers details about the user. "
"Use the following memory context to answer in a personal way.\n\n"
f"MEMORY CONTEXT:\n{contextblock}"
)
# 2. Call the LLM
completion = oai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": systemprompt},
{"role": "user", "content": userinput},
],
)
answer = completion.choices[0].message.content
# 3. Save the exchange back to Zep
zep.thread.addmessages(
threadid=THREADID,
messages=[
Message(role="user", name="Ruby", content=userinput),
Message(role="assistant", name="Assistant", content=answer),
],
)
return answer
if name == "main":
ensuresetup()
print("Chatbot ready. Type 'exit' to quit.")
while True:
userinput = input("You: ")
if userinput.strip().lower() in ("exit", "quit"):
break
response = chat(userinput)
print(f"Assistant: {response}")
Try running it and chatting a few times. In the first session, just state your preferences, for example "I prefer short answers" or "call me Boss Ruby". Then in later turns, Zep will have captured that fact and injected it via contextblock, so the assistant automatically adapts. The cool part is that if you close the program and reopen it with the same THREADID, the memory is still there. That is the essence of long-term memory.
Advanced Usage
Search facts in the knowledge graph
Beyond pulling per-thread context, Zep gives us direct access to the knowledge graph for more specific searches. This is really handy when your agent needs to find a particular fact across conversations, not just a summary of the active thread.
There are two main search types: searching edges (facts or relationships) and searching nodes (entities). Here is an example of searching relevant facts for a query:
results = zep.graph.search(
userid="user-ruby-001",
query="database and programming language preferences",
scope="edges",
limit=5,
)
for edge in results.edges or []:
print("Fact:", edge.fact)
Here we search across the entire graph belonging to the user, not just one thread. scope="edges" means we search for facts/relationships. If we want to search for entities, change it to scope="nodes":
noderesults = zep.graph.search(
userid="user-ruby-001",
query="projects currently being worked on",
scope="nodes",
limit=5,
)
for node in noderesults.nodes or []:
print("Entity:", node.name, "-", node.summary)
This graph search is what makes Zep feel like a real "brain". Your agent can ask "what are the user's communication preferences?" and get relevant facts that have been collected from many prior conversations.
Add data directly to the graph
Memory does not always have to come from chat conversations. Sometimes you have structured data or documents you want to add to a user's memory, for example a CRM profile or meeting notes. Zep lets us add raw data directly to the graph.
# Adding free text as a source of facts
zep.graph.add(
userid="user-ruby-001",
type="text",
data="Ruby is a lead engineer on the data team. He manages 3 juniors "
"and is responsible for the daily ETL pipeline.",
)
Adding structured JSON data
import json
profile = {
"role": "Lead Data Engineer",
"tools": ["Airflow", "dbt", "PostgreSQL"],
"timezone": "Asia/Jakarta",
}
zep.graph.add(
userid="user-ruby-001",
type="json",
data=json.dumps(profile),
)
print("Additional data successfully added to the graph")
With this, you can seed your agent's memory using data you already have, instead of waiting for the user to tell everything from scratch. Zep extracts facts from that text or JSON and merges them into the same graph.
Managing multiple sessions per user
In a real application, one user can have many threads: yesterday's chat, today's chat, a chat about topic A, a chat about topic B. The nice thing about Zep's model is that even though the threads differ, the knowledge graph is tied to the user level. So a fact learned in thread A can still be used when the agent serves thread B, as long as it is the same user.
# A new thread for another topic, still the same user
zep.thread.create(threadid="thread-topic-career", userid="user-ruby-001")
zep.thread.addmessages(
threadid="thread-topic-career",
messages=[
Message(role="user", name="Ruby",
content="I'm thinking about moving into an MLOps role next year."),
],
)
When pulling context for the old thread, this career fact can appear too
ctx = zep.thread.getusercontext(threadid="thread-chatbot-demo")
print(ctx.context)
This is very different from storing per-thread history only. Zep gives memory that is unified at the user level, so your agent has a complete picture of the person, not just fragments per conversation.
Handling facts that change over time
Back to the word "temporal". Say the user initially says they live in Jakarta, then a few months later says they moved to Bandung. Zep records both with temporal info: the Jakarta fact is valid from some date until the move, and the Bandung fact is valid from the move until now. An agent asking "where does the user live now?" gets Bandung, but if you need the history, the data is still there.
# Simulate a fact update
zep.thread.addmessages(
threadid="thread-chatbot-demo",
messages=[
Message(role="user", name="Ruby",
content="Oh by the way, I've now relocated to Bandung."),
],
)
Search for the latest domicile fact
res = zep.graph.search(
userid="user-ruby-001",
query="user's current city of residence",
scope="edges",
limit=3,
)
for edge in res.edges or []:
print("Fact:", edge.fact)
This ability to handle changing facts is what makes Zep suitable for long-lived production agents. Users change, and your agent's memory must update along with them without losing historical context.
Best Practices
After experimenting with Zep several times, there are a few things I feel are important to keep in mind so your implementation stays clean and economical.
Use a stable, deterministic userid. Do not generate a random one each time. Use the ID from your own authentication system so you can always pull back the memory of the same person across devices and across sessions. This is the foundation for long-term memory to truly work. Store messages consistently, not just partially. If you only save the user's messages but forget to save the assistant's replies, the quality of fact extraction drops because Zep loses half the conversation context. Always save both sides of the exchange. Inject context, not the entire transcript. The temptation to drag all history into the prompt is strong, but do not. Usegetusercontext to get a dense summary. This is what keeps token cost under control and latency stable even when the conversation has thousands of turns. This is precisely Zep's main advantage over piling up transcripts.
Be aware of the asynchronous nature of graph extraction. New facts take a little time to be processed on Zep's side. Do not write logic that immediately asserts a new fact appears exactly one second after adding a message. If you need to test, add a few seconds of delay or a retry.
Protect your API key. Always keep it in an environment variable or secret manager, never hardcoded or committed. Rotate the key if you suspect a leak. This is basic but often forgotten when chasing a deadline.
Design a clear threading strategy. Decide from the start whether you want one long thread per user, or a new thread per session. For most chatbot cases, one thread per conversation session makes sense, and let the user-level knowledge graph unify everything.
Leverage graph.add for seeding. Do not wait for the user to tell everything. If you already have a user profile from another source, add it to the graph so the agent immediately has rich initial context. This makes the first interaction experience much more personal.
Handle errors gracefully. Network calls can fail. Wrap Zep operations in try/except and prepare a fallback, for example if getuser_context fails, the agent can still answer with empty context instead of crashing entirely. A chatbot that keeps running even when memory is momentarily unreadable is better than one that dies suddenly.
Mind privacy and data retention. Since Zep stores personal facts about the user, make sure you have a retention policy and a way to delete a user's data on request. This matters for compliance such as GDPR. Zep provides operations to delete a user along with their memory, so use that in your account deletion flow.
Conclusion
Alright, we have covered quite a long journey. We started from why memory is crucial for AI agents, then got acquainted with the temporal knowledge graph concept that makes Zep different from simply storing chat history. We signed up for Zep Cloud, grabbed an API key, installed zep-cloud, then practiced creating users, creating threads, adding messages, pulling context, all the way to searching facts in the graph. The climax was assembling a simple chatbot that truly remembers who you are across sessions.
What I want to emphasize is that Zep's power is not just "storing data". Its power lies in how it turns raw conversations into structured facts that carry a time dimension, then serves them back as dense context you can inject straight into the prompt. This solves three problems at once: the agent becomes personal, token cost stays under control, and changing facts remain neatly recorded.
If you are building an AI assistant, a customer support agent, or a personal tutor, try integrating memory like this from the start. Trust me, an agent that remembers the user feels a whole class above one that goes amnesiac every session. The next steps I recommend: experiment with graph.add to seed user profiles, try separating multiple threads per user, and play with graph search for more specific retrieval use cases. Happy coding, and may your agents get better and better at remembering their friends. See you in the next tutorial.