Zep Tutorial: Long-Term Memory for AI Agents with a Temporal Knowledge Graph

# Zep: Bikin AI Agent Punya Memori Jangka Panjang dengan Temporal Knowledge Graph Temen-temen, pernah ngobrol sama chatbot yang tiap kali kamu buka percakapan baru dia lupa total sama siapa kamu? Kam...

By Ruby Abdullah · · tutorial
ZepAI AgentsMemoryPythonLLM

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:

  • Store the raw message in the session history.
  • Extract entities and facts from that message (for example "Ruby likes coffee without sugar", "the Aurora project deadline is August").
  • Store those facts as nodes and relationships in the temporal knowledge graph.
  • Record validity timestamps for the facts, so if the user later says "now I like coffee with sugar", the old fact is marked as no longer valid and a new fact is created.
  • 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:

  • Open the Zep website at getzep.com and sign up for an account. You can use email or Google login.
  • Once in the dashboard, find the Projects menu. Create a new project and give it a name, for example "tutorial-memory".
  • Inside that project there is an API Keys section. Generate a new API key and copy it. Keep it safe, since the key is usually shown only once.
  • 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.addmessages(

    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 threaddata.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. Use getusercontext 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.

    Related Articles

    LangChain Tutorial: The Most Popular Framework for Building LLM Applications

    Tutorial LangChain: Framework Paling Populer untuk Membangun Aplikasi LLM LangChain adalah framework open-source yang di...

    PydanticAI Tutorial: A Type-Safe Agent Framework for LLM Apps

    Membangun Agen LLM yang Type-Safe dengan PydanticAI PydanticAI adalah framework agen dari tim di balik Pydantic, diranca...

    Complete LangGraph Tutorial: Building Complex AI Agents

    Tutorial Lengkap LangGraph: Membangun AI Agents yang Kompleks LangGraph adalah library dari LangChain untuk membangun st...

    DSPy: Stop Hand-Tuning Prompts, Let the Compiler Optimize Them

    DSPy: Berhenti Ngoprek Prompt Manual, Biarkan Compiler yang Optimasi Halo temen-temen, kali ini aku mau ngenalin satu li...